From 86b7485b323cc0cb8ca5bba69c8e033282ef030a Mon Sep 17 00:00:00 2001 From: pigui Date: Tue, 29 Sep 2026 15:21:59 +0200 Subject: [PATCH] Tell search engines and link previews what each page is - /sitemap.xml, from the API's /slugs plus the landing and listings: every address in every language, with hreflang and x-default. A 503 when the API is down, rather than a sitemap missing half the site. - /robots.txt, naming the sitemap on whatever domain this is served on. - Slugs renamed in the panel now redirect. The API has always kept the old address the moment one changed; middleware/redirects.ts answers it with a 301, from a list kept for a minute and through outages. - og:url, og:image (the record's own picture, the logo otherwise) and schema.org JSON-LD: Organization on the landing; WebPage, CreativeWork, Person or Service elsewhere. Shared through core/head. - The proxy-derived origin moves to core/origin.ts, used by the page shell, the landing and both server routes. In development only /api/* reaches Nitro, so the two files 404 under `npm start`; they were checked against a production build. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++++++ landing/src/app/core/api.ts | 21 +++++ landing/src/app/core/head.spec.ts | 42 ++++++++++ landing/src/app/core/head.ts | 56 +++++++++++++ landing/src/app/core/origin.spec.ts | 38 +++++++++ landing/src/app/core/origin.ts | 27 +++++++ landing/src/app/core/redirects.spec.ts | 40 +++++++++ landing/src/app/core/redirects.ts | 28 +++++++ landing/src/app/core/sitemap.spec.ts | 49 +++++++++++ landing/src/app/core/sitemap.ts | 85 ++++++++++++++++++++ landing/src/app/pages/(home).page.ts | 19 ++++- landing/src/app/ui/page-shell.ts | 47 ++++++----- landing/src/app/ui/routed-page.ts | 60 ++++++++++++++ landing/src/server/middleware/redirects.ts | 59 ++++++++++++++ landing/src/server/routes/robots.txt.get.ts | 21 +++++ landing/src/server/routes/sitemap.xml.get.ts | 34 ++++++++ tools/mock-api.mjs | 12 +++ 17 files changed, 639 insertions(+), 22 deletions(-) create mode 100644 landing/src/app/core/head.spec.ts create mode 100644 landing/src/app/core/head.ts create mode 100644 landing/src/app/core/origin.spec.ts create mode 100644 landing/src/app/core/origin.ts create mode 100644 landing/src/app/core/redirects.spec.ts create mode 100644 landing/src/app/core/redirects.ts create mode 100644 landing/src/app/core/sitemap.spec.ts create mode 100644 landing/src/app/core/sitemap.ts create mode 100644 landing/src/server/middleware/redirects.ts create mode 100644 landing/src/server/routes/robots.txt.get.ts create mode 100644 landing/src/server/routes/sitemap.xml.get.ts diff --git a/README.md b/README.md index e12d6da..9a659ac 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,9 @@ landing/src/ server/ routes/api/contact.post.ts forwards the contact form to the API routes/api/calls/[slug]/apply.post.ts forwards an application to one call + routes/sitemap.xml.get.ts every published address, from the API's /slugs + routes/robots.txt.get.ts points crawlers at the sitemap + middleware/redirects.ts 301s for slugs renamed in the panel middleware/not-found-status.ts styles.scss Material theme, `@pi/ui` sheets, shared primitives ``` @@ -251,6 +254,26 @@ cannot hold two parameters of different names at the same depth — Featured pages go in the header after the section anchors; every page is in the footer. +## Search engines and link previews + +- **`/sitemap.xml`** comes from the API's `/slugs` — one request for every + published address — plus the landing and the listings. Each address is listed + in every language, with `hreflang` alternates and `x-default`. If the API is + down it answers 503, not a sitemap missing half the site. +- **`/robots.txt`** allows everything and names the sitemap, absolute, on + whatever domain the proxy says this is. +- **Renamed slugs redirect.** The panel writes a redirect the moment somebody + renames one; `middleware/redirects.ts` answers the old address with a 301, + from the API's `/redirects` list kept for a minute (and kept whole through an + outage, so renamed links do not turn back into 404s). +- **Every page says what it is**: title, description, `og:*` with the record's + own picture (the logo otherwise), and schema.org JSON-LD — `Organization` on + the landing, `WebPage`, `CreativeWork`, `Person` or `Service` elsewhere. + +In development only `/api/*` reaches Nitro, so `/sitemap.xml` and +`/robots.txt` answer 404 under `npm start`; build and run `npm run preview` to +see them. Middleware does run in development. + ## Rendering and caching Nothing is prerendered: the page is assembled from content the panel publishes diff --git a/landing/src/app/core/api.ts b/landing/src/app/core/api.ts index 5d2cc00..9feffc5 100644 --- a/landing/src/app/core/api.ts +++ b/landing/src/app/core/api.ts @@ -185,7 +185,28 @@ async function list(path: string, options: GetOptions = {}): Promise; +} + +export interface Redirect { + from: string; + to: string; + status: 301 | 302; + /** Null: applies whatever language the visitor is browsing in. */ + locale?: string | null; +} + export const pireonApi = { + /** Every published address, grouped by type. What the sitemap is built from. */ + slugs: () => get>('/slugs'), + + /** Addresses that used to work and where they go now. Whole: there are never many. */ + redirects: () => list('/redirects', { timeout: 3000 }), + site: (lang: Locale) => get('/site', { lang }), services: (lang: Locale, query?: GetOptions['query']) => diff --git a/landing/src/app/core/head.spec.ts b/landing/src/app/core/head.spec.ts new file mode 100644 index 0000000..4109d2d --- /dev/null +++ b/landing/src/app/core/head.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { absoluteUrl, setStructuredData } from './head'; + +describe('absoluteUrl', () => { + it('puts a path on the origin and leaves a URL alone', () => { + expect(absoluteUrl('https://pireon.dev', '/logo.png')).toBe( + 'https://pireon.dev/logo.png', + ); + expect(absoluteUrl('https://pireon.dev', 'https://cdn.example/a.png')).toBe( + 'https://cdn.example/a.png', + ); + expect(absoluteUrl(null, '/logo.png')).toBe('/logo.png'); + }); +}); + +describe('setStructuredData', () => { + it("writes one script, replacing the previous page's", () => { + setStructuredData(document, { '@type': 'Person', name: 'Ada' }); + setStructuredData(document, { '@type': 'Service', name: 'Formación' }); + + const scripts = document.head.querySelectorAll( + 'script[type="application/ld+json"]', + ); + + expect(scripts).toHaveLength(1); + expect(JSON.parse(scripts[0].textContent ?? '')).toEqual({ + '@context': 'https://schema.org', + '@type': 'Service', + name: 'Formación', + }); + }); + + it('cannot be closed early by what it carries', () => { + setStructuredData(document, { name: '' }); + + expect( + document.head.querySelector('script[type="application/ld+json"]') + ?.textContent, + ).not.toContain(''); + }); +}); diff --git a/landing/src/app/core/head.ts b/landing/src/app/core/head.ts new file mode 100644 index 0000000..cd2eda5 --- /dev/null +++ b/landing/src/app/core/head.ts @@ -0,0 +1,56 @@ +/** + * The parts of `` that describe a page to machines rather than people: + * the preview a chat app or social network draws, and schema.org data a + * search engine reads. Shared by the landing and every page reached by its + * address, so both say it the same way. + */ +import type { Meta } from '@angular/platform-browser'; + +const JSON_LD_ID = 'page-structured-data'; + +/** Relative paths become absolute on `origin`; absolute URLs pass through. */ +export function absoluteUrl(origin: string | null, href: string): string { + return /^https?:\/\//.test(href) || !origin ? href : `${origin}${href}`; +} + +/** + * `og:image` and the card size that goes with it. Without an image of the + * record's own, the site's logo, as a small card rather than a stretched one. + */ +export function setShareImage( + meta: Meta, + origin: string | null, + image?: string | null, +): void { + meta.updateTag({ + property: 'og:image', + content: absoluteUrl(origin, image || '/logo.png'), + }); + meta.updateTag({ + name: 'twitter:card', + content: image ? 'summary_large_image' : 'summary', + }); +} + +/** One `