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