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 `