From e17332701caede9443139fce831ae5fb05dadadb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:52:01 +0000 Subject: [PATCH] fix(docs): give each page its own description and canonical URL, and stop layout shifts Every page shipped the homepage description, og:description and og:url, and none had a canonical link, because Analog's markdown routes drop the frontmatter description. The page-meta map now carries each page's description and noIndex, and the title strategy sets the description, canonical link, og:url and social titles on every navigation. Pages with `noIndex` get `robots: noindex`, so the kitchen sink stays out of search. The live site answered every link and sitemap URL with a 307 to its trailing-slash form, where the client briefly rendered the homepage before the page. Workers now serve `/guide` from `guide/index.html` (`html_handling: drop-trailing-slash`). `` rendered an empty placeholder that the browser filled in later, so screenshots pushed the page down (desktop CLS about 0.2). It now renders its figure at build time, with the image's width and height read from public/, so the image is in the prerendered HTML and its space is reserved. --- apps/docs/image-sizes.spec.ts | 52 ++++++++++++++ apps/docs/image-sizes.ts | 52 ++++++++++++++ apps/docs/page-meta.plugin.ts | 23 +++++- .../docs/src/app/components/media-enhancer.ts | 43 ++--------- .../page-meta/page-meta.service.spec.ts | 71 +++++++++++++++++++ .../services/page-meta/page-meta.service.ts | 53 ++++++++++++++ apps/docs/src/app/title-strategy.ts | 14 +++- apps/docs/src/marked-extensions/index.ts | 9 ++- .../marked-extensions.spec.ts | 22 ++++-- apps/docs/src/marked-extensions/ngmd-image.ts | 71 +++++++++++-------- apps/docs/src/marked-extensions/runtime.ts | 21 +++--- apps/docs/src/vite-env.d.ts | 2 + apps/docs/wrangler.jsonc | 3 + 13 files changed, 348 insertions(+), 88 deletions(-) create mode 100644 apps/docs/image-sizes.spec.ts create mode 100644 apps/docs/image-sizes.ts create mode 100644 apps/docs/src/app/services/page-meta/page-meta.service.spec.ts create mode 100644 apps/docs/src/app/services/page-meta/page-meta.service.ts diff --git a/apps/docs/image-sizes.spec.ts b/apps/docs/image-sizes.spec.ts new file mode 100644 index 00000000..4aef6d51 --- /dev/null +++ b/apps/docs/image-sizes.spec.ts @@ -0,0 +1,52 @@ +import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {imageSize, readImageSizes} from './image-sizes'; + +function png(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return bytes; +} + +function jpeg(width: number, height: number): Uint8Array { + // SOI, an APP0 segment, then a baseline start-of-frame segment. + const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; + const sof = [ + 0xff, + 0xc0, + 0x00, + 0x11, + 0x08, + height >> 8, + height & 0xff, + width >> 8, + width & 0xff, + 0x03, + ]; + return new Uint8Array([0xff, 0xd8, ...app0, ...sof, ...new Array(12).fill(0)]); +} + +describe('imageSize', () => { + it('reads PNG and JPEG headers', () => { + expect(imageSize(png(1280, 800))).toEqual([1280, 800]); + expect(imageSize(jpeg(640, 480))).toEqual([640, 480]); + }); + + it('returns undefined for other files', () => { + expect(imageSize(new TextEncoder().encode(''))).toBeUndefined(); + }); +}); + +describe('readImageSizes', () => { + it('keys the sizes by URL path', () => { + const dir = mkdtempSync(join(tmpdir(), 'image-sizes-')); + mkdirSync(join(dir, 'images/panel'), {recursive: true}); + writeFileSync(join(dir, 'images/panel/router.png'), png(1280, 800)); + writeFileSync(join(dir, 'logo.svg'), ''); + expect(readImageSizes(dir)).toEqual({'/images/panel/router.png': [1280, 800]}); + }); +}); diff --git a/apps/docs/image-sizes.ts b/apps/docs/image-sizes.ts new file mode 100644 index 00000000..8a46c6e3 --- /dev/null +++ b/apps/docs/image-sizes.ts @@ -0,0 +1,52 @@ +import {readFileSync, readdirSync, statSync} from 'node:fs'; +import {join, relative, sep} from 'node:path'; + +/** + * Width and height in pixels, by the public URL of the image + * (`/images/panel/router.png`). The build-time `` writes them into + * the `width` and `height` of its ``, so the browser reserves the image's + * space before it loads and the page does not shift (CLS). + */ +export type ImageSizes = Record; + +/** The sizes of the PNG and JPEG files under `dir`, keyed by their URL path. */ +export function readImageSizes(dir: string): ImageSizes { + const sizes: ImageSizes = {}; + const walk = (current: string) => { + for (const name of readdirSync(current)) { + const file = join(current, name); + if (statSync(file).isDirectory()) walk(file); + else if (/\.(png|jpe?g)$/i.test(name)) { + const size = imageSize(readFileSync(file)); + if (size) sizes['/' + relative(dir, file).split(sep).join('/')] = size; + } + } + }; + try { + walk(dir); + } catch { + // no public/ directory + } + return sizes; +} + +/** Width and height from a PNG or JPEG header, or `undefined` for anything else. */ +export function imageSize(bytes: Uint8Array): [number, number] | undefined { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + // PNG: signature, then the IHDR chunk with width and height. + if (bytes.length >= 24 && view.getUint32(0) === 0x89504e47) + return [view.getUint32(16), view.getUint32(20)]; + // JPEG: walk the segments to the first start-of-frame marker. + if (bytes.length >= 4 && view.getUint16(0) === 0xffd8) { + let offset = 2; + while (offset + 9 < bytes.length) { + if (bytes[offset] !== 0xff) return undefined; + const marker = bytes[offset + 1]; + const length = view.getUint16(offset + 2); + const isFrame = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker); + if (isFrame) return [view.getUint16(offset + 7), view.getUint16(offset + 5)]; + offset += 2 + length; + } + } + return undefined; +} diff --git a/apps/docs/page-meta.plugin.ts b/apps/docs/page-meta.plugin.ts index 685941af..4602c1d8 100644 --- a/apps/docs/page-meta.plugin.ts +++ b/apps/docs/page-meta.plugin.ts @@ -1,10 +1,17 @@ -import {statSync} from 'node:fs'; +import {readFileSync, statSync} from 'node:fs'; import {join} from 'node:path'; import type {Plugin, ViteDevServer} from 'vite'; -import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; +import { + gitDate, + isNoIndex, + parseFrontmatter, + routeFromPagePath, + walkContentFiles, + walkPageFiles, +} from './plugin-utils.ts'; /** - * Build-time map of page URL → { editUrl, lastUpdated }. + * Build-time map of page URL → { editUrl, lastUpdated, description, noIndex }. * * Walks `src/app/pages` (for `.page.ts` routes) and `src/content/**\/*.md` * (each markdown file's path under content/ becomes its route, matching the @@ -14,11 +21,16 @@ import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plu * * If the file is uncommitted, lastUpdated falls back to its mtime in ISO * date form so dev iteration still shows something. + * + * A markdown page also carries its frontmatter `description` and `noIndex`, + * which `PageMetaService` writes into the page's head tags. */ export interface PageMeta { editUrl: string; lastUpdated: string; + description?: string; + noIndex?: boolean; } const VIRTUAL_ID = 'virtual:ngmd/page-meta'; @@ -73,10 +85,15 @@ export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: st for (const [rel, route] of walkContentFiles(contentDir, root)) { const date = gitDate(rel, root); if (!date) continue; + const {attributes} = parseFrontmatter(readFileSync(join(root, rel), 'utf8')); // .md edit URL wins when present (more useful for prose pages) map[route] = { editUrl: `${opts.repoUrl}/edit/${branch}/${dir}${rel}`, lastUpdated: date, + ...(typeof attributes['description'] === 'string' + ? {description: attributes['description']} + : {}), + ...(isNoIndex(attributes) ? {noIndex: true} : {}), }; } } catch { diff --git a/apps/docs/src/app/components/media-enhancer.ts b/apps/docs/src/app/components/media-enhancer.ts index a5bccd15..18d1fb8c 100644 --- a/apps/docs/src/app/components/media-enhancer.ts +++ b/apps/docs/src/app/components/media-enhancer.ts @@ -3,11 +3,11 @@ import {Router} from '@angular/router'; import {enhanceOnNavigation} from '../utils/enhance-on-navigation'; /** - * Hydrates the placeholder divs emitted by the ngmd-video and ngmd-image - * marked extensions. The marked extensions output plain `
` - * elements; this enhancer creates the real