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 and - * structures programmatically — bypassing any Angular [innerHTML] - * sanitisation that may strip iframes in some configurations. + * Hydrates the placeholder divs emitted by the ngmd-video marked extension. + * It outputs plain `` elements; this enhancer creates the real + * programmatically, bypassing any Angular [innerHTML] sanitisation + * that may strip iframes in some configurations. `` renders its + * `` directly, so it is in the prerendered HTML. */ @Component({ selector: 'app-media-enhancer', @@ -29,12 +29,6 @@ export class MediaEnhancer implements AfterViewInit { '.ngmd-video[data-video-src]:not([data-enhanced])', (el) => this.enhanceVideo(el), ); - enhanceOnNavigation( - this.router, - this.destroyRef, - '.ngmd-image[data-image-src]:not([data-enhanced])', - (el) => this.enhanceImage(el), - ); } private enhanceVideo(el: HTMLElement): void { @@ -49,31 +43,4 @@ export class MediaEnhancer implements AfterViewInit { iframe.loading = 'lazy'; el.appendChild(iframe); } - - private enhanceImage(el: HTMLElement): void { - el.setAttribute('data-enhanced', 'true'); - const src = el.dataset['imageSrc'] ?? ''; - const alt = el.dataset['imageAlt'] ?? ''; - const caption = el.dataset['imageCaption'] ?? ''; - const width = el.dataset['imageWidth'] ?? ''; - - // Replace the placeholder with an actual - const figure = document.createElement('figure'); - figure.className = 'ngmd-image'; - if (width) figure.style.maxWidth = /^\d+(\.\d+)?$/.test(width) ? `${width}px` : width; - - const img = document.createElement('img'); - img.src = src; - img.alt = alt; - img.loading = 'lazy'; - figure.appendChild(img); - - if (caption) { - const figcaption = document.createElement('figcaption'); - figcaption.textContent = caption; - figure.appendChild(figcaption); - } - - el.replaceWith(figure); - } } diff --git a/apps/docs/src/app/services/page-meta/page-meta.service.spec.ts b/apps/docs/src/app/services/page-meta/page-meta.service.spec.ts new file mode 100644 index 00000000..8c064c39 --- /dev/null +++ b/apps/docs/src/app/services/page-meta/page-meta.service.spec.ts @@ -0,0 +1,71 @@ +import {DOCUMENT} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {Meta} from '@angular/platform-browser'; +import siteConfig from '../../../ngmd.config'; +import {PageMetaService, canonicalUrl} from './page-meta.service'; + +vi.mock('virtual:ngmd/page-meta', () => ({ + pageMeta: { + '/inspectors/router': {editUrl: '', lastUpdated: '', description: 'The Router tab.'}, + '/contributing/kitchen-sink': {editUrl: '', lastUpdated: '', noIndex: true}, + }, +})); + +describe('canonicalUrl', () => { + it('drops the query, fragment and trailing slash', () => { + expect(canonicalUrl('/inspectors/router/?tab=1#agent-tools')).toBe( + 'https://pangular-inspector.dev/inspectors/router', + ); + }); + + it('keeps the slash of the homepage', () => { + expect(canonicalUrl('/')).toBe('https://pangular-inspector.dev/'); + }); +}); + +describe('PageMetaService', () => { + const tag = (selector: string) => TestBed.inject(Meta).getTag(selector)?.content; + + it('sets the canonical link, og:url, titles and the page description', () => { + TestBed.inject(PageMetaService).setPage( + '/inspectors/router#agent-tools', + 'Pangular Inspector | Router', + ); + + const canonical = TestBed.inject(DOCUMENT).head.querySelector('link[rel="canonical"]'); + expect(canonical?.getAttribute('href')).toBe( + 'https://pangular-inspector.dev/inspectors/router', + ); + expect(tag('property="og:url"')).toBe('https://pangular-inspector.dev/inspectors/router'); + expect(tag('property="og:title"')).toBe('Pangular Inspector | Router'); + expect(tag('name="twitter:title"')).toBe('Pangular Inspector | Router'); + for (const selector of [ + 'name="description"', + 'property="og:description"', + 'name="twitter:description"', + ]) + expect(tag(selector)).toBe('The Router tab.'); + expect(tag('name="robots"')).toBeUndefined(); + }); + + it('falls back to the site description, and marks a noIndex page', () => { + const pageMeta = TestBed.inject(PageMetaService); + + pageMeta.setPage('/contributing/kitchen-sink', 'Kitchen sink'); + expect(tag('name="description"')).toBe(siteConfig.site.description); + expect(tag('name="robots"')).toBe('noindex'); + + pageMeta.setPage('/inspectors/router', 'Router'); + expect(tag('name="robots"')).toBeUndefined(); + }); + + it('keeps one canonical link across pages', () => { + const pageMeta = TestBed.inject(PageMetaService); + pageMeta.setPage('/a', 'A'); + pageMeta.setPage('/b', 'B'); + + const links = TestBed.inject(DOCUMENT).head.querySelectorAll('link[rel="canonical"]'); + expect(links.length).toBe(1); + expect(links[0].getAttribute('href')).toBe('https://pangular-inspector.dev/b'); + }); +}); diff --git a/apps/docs/src/app/services/page-meta/page-meta.service.ts b/apps/docs/src/app/services/page-meta/page-meta.service.ts new file mode 100644 index 00000000..07900202 --- /dev/null +++ b/apps/docs/src/app/services/page-meta/page-meta.service.ts @@ -0,0 +1,53 @@ +import {DOCUMENT, Service, inject} from '@angular/core'; +import {Meta} from '@angular/platform-browser'; +import {pageMeta} from 'virtual:ngmd/page-meta'; +import siteConfig from '../../../ngmd.config'; +import {stripUrl} from '../route-url/route-url.service'; + +/** + * The head tags that differ per page: the canonical URL, `og:url`, the + * Open Graph and Twitter titles, the description in its three tags, and + * `robots: noindex` for a page whose frontmatter sets `noIndex`. The + * description comes from the page's frontmatter (see `page-meta.plugin.ts`), + * or is the site's. `index.html` holds the site-wide values, and the + * prerendered HTML carries each page's own. + */ +@Service() +export class PageMetaService { + private readonly meta = inject(Meta); + private readonly document = inject(DOCUMENT); + + /** Point the tags at the page at `url`, titled `title`. */ + setPage(url: string, title: string): void { + const canonical = canonicalUrl(url); + const page = pageMeta[stripUrl(url)]; + const description = page?.description?.trim() || siteConfig.site.description; + + this.meta.updateTag({property: 'og:url', content: canonical}); + this.meta.updateTag({property: 'og:title', content: title}); + this.meta.updateTag({name: 'twitter:title', content: title}); + this.meta.updateTag({name: 'description', content: description}); + this.meta.updateTag({property: 'og:description', content: description}); + this.meta.updateTag({name: 'twitter:description', content: description}); + if (page?.noIndex) this.meta.updateTag({name: 'robots', content: 'noindex'}); + else this.meta.removeTag('name="robots"'); + this.setCanonical(canonical); + } + + private setCanonical(href: string): void { + let link = this.document.head.querySelector('link[rel="canonical"]'); + if (!link) { + link = this.document.createElement('link'); + link.setAttribute('rel', 'canonical'); + this.document.head.appendChild(link); + } + link.setAttribute('href', href); + } +} + +/** The absolute URL of a page, without a query, fragment or trailing slash, as the sitemap lists it. */ +export function canonicalUrl(url: string): string { + const base = siteConfig.site.url.replace(/\/+$/, ''); + const path = stripUrl(url); + return path === '/' ? `${base}/` : `${base}${path}`; +} diff --git a/apps/docs/src/app/title-strategy.ts b/apps/docs/src/app/title-strategy.ts index 6c060970..ce69980b 100644 --- a/apps/docs/src/app/title-strategy.ts +++ b/apps/docs/src/app/title-strategy.ts @@ -2,6 +2,7 @@ import {Injectable, inject} from '@angular/core'; import {Title} from '@angular/platform-browser'; import {TitleStrategy, type RouterStateSnapshot} from '@angular/router'; import siteConfig, {navItems, navLabels} from '../ngmd.config'; +import {PageMetaService} from './services/page-meta/page-meta.service'; import {stripUrl} from './services/route-url/route-url.service'; /** @@ -15,11 +16,13 @@ import {stripUrl} from './services/route-url/route-url.service'; * 4. `siteConfig.site.tagline` for the homepage. * * Replaces Angular's `DefaultTitleStrategy`, which would otherwise overwrite - * our format with just the raw frontmatter title. + * our format with just the raw frontmatter title. It also points the + * canonical URL and the social tags at the page (see `PageMetaService`). */ @Injectable({providedIn: 'root'}) export class NgmdTitleStrategy extends TitleStrategy { private readonly title = inject(Title); + private readonly pageMeta = inject(PageMetaService); override updateTitle(snapshot: RouterStateSnapshot): void { const name = siteConfig.site.name; @@ -28,7 +31,7 @@ export class NgmdTitleStrategy extends TitleStrategy { // Homepage uses the tagline in place of a page title. if (url === '/' || url === '') { const tagline = siteConfig.site.tagline; - this.title.setTitle(tagline ? `${name} | ${tagline}` : name); + this.setTitle(url, tagline ? `${name} | ${tagline}` : name); return; } @@ -43,6 +46,11 @@ export class NgmdTitleStrategy extends TitleStrategy { .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) .join(' '); } - this.title.setTitle(pageTitle ? `${name} | ${pageTitle}` : name); + this.setTitle(url, pageTitle ? `${name} | ${pageTitle}` : name); + } + + private setTitle(url: string, title: string): void { + this.title.setTitle(title); + this.pageMeta.setPage(url, title); } } diff --git a/apps/docs/src/marked-extensions/index.ts b/apps/docs/src/marked-extensions/index.ts index 4e6be6b6..e4ee96d1 100644 --- a/apps/docs/src/marked-extensions/index.ts +++ b/apps/docs/src/marked-extensions/index.ts @@ -1,5 +1,5 @@ import type {MarkedExtension} from 'marked'; -import {ngmdRuntimeExtensions} from './runtime.ts'; +import {createNgmdRuntimeExtensions, ngmdRuntimeExtensions} from './runtime.ts'; export {ngmdRuntimeExtensions}; @@ -27,14 +27,19 @@ export async function getBuildExtensions(): Promise { {ngmdCodeGroupExtension}, {ngmdCodeHighlightExtension}, {substituteMdVars}, + {readImageSizes}, ] = await Promise.all([ import('./ngmd-code-import.ts'), import('./ngmd-code-group.ts'), import('./ngmd-code-highlight.ts'), import('../../vars.plugin.ts'), + import('../../image-sizes.ts'), ]); return [ - ...ngmdRuntimeExtensions, + // The pages are rendered here, so `` gets the sizes of the files in public/. + ...createNgmdRuntimeExtensions( + readImageSizes(new URL('../../public', import.meta.url).pathname), + ), ngmdCodeImportExtension, ngmdCodeGroupExtension, ngmdCodeHighlightExtension, diff --git a/apps/docs/src/marked-extensions/marked-extensions.spec.ts b/apps/docs/src/marked-extensions/marked-extensions.spec.ts index 494c4390..eb9b086b 100644 --- a/apps/docs/src/marked-extensions/marked-extensions.spec.ts +++ b/apps/docs/src/marked-extensions/marked-extensions.spec.ts @@ -1,6 +1,6 @@ import {Marked} from 'marked'; import {findFences, getAttr, hasFlag} from './fences'; -import {ngmdImageExtension} from './ngmd-image'; +import {createNgmdImageExtension, ngmdImageExtension} from './ngmd-image'; import {ngmdKeywordsExtension} from './ngmd-keywords'; import {ngmdVideoExtension} from './ngmd-video'; import {ngmdRuntimeExtensions} from './runtime'; @@ -14,14 +14,24 @@ function render(markdown: string): string { } describe('ngmd-image', () => { - it('escapes every attribute it writes', () => { + it('renders the figure and escapes every attribute it writes', () => { const html = render( '', ); - expect(html).toContain('data-image-src="/a.png?x=1&y=2"'); - expect(html).toContain('data-image-alt="<b"'); - expect(html).toContain('data-image-caption="Tom & Jerry"'); - expect(html).toContain('data-image-width="3<"'); + expect(html).toContain(''); + expect(html).toContain( + '', + ); + expect(html).toContain('Tom & Jerry'); + }); + + it('gives the image its width and height when its size is known', () => { + const html = new Marked({ + extensions: [createNgmdImageExtension({'/images/a.png': [1280, 800]})], + }).parse('') as string; + expect(html).toBe( + '', + ); }); }); diff --git a/apps/docs/src/marked-extensions/ngmd-image.ts b/apps/docs/src/marked-extensions/ngmd-image.ts index 6d253dd9..85920b8c 100644 --- a/apps/docs/src/marked-extensions/ngmd-image.ts +++ b/apps/docs/src/marked-extensions/ngmd-image.ts @@ -16,31 +16,46 @@ interface NgmdImageToken extends Tokens.Generic { const tagRule = /^]*?)(?:\/>|>\s*<\/ngmd-image>)/s; const attrRule = (name: string) => new RegExp(`${name}="([^"]*)"`); -export const ngmdImageExtension = { - name: 'ngmd-image', - level: 'block' as const, - start(src: string) { - return src.match(/^\s*`; - }, -}; +/** Width and height by image URL; see `image-sizes.ts`. */ +export type NgmdImageSizes = Record; + +/** + * `` rendered as its final ``, so the image is in the + * prerendered HTML. With the image's size from `sizes`, the `` gets a + * `width` and `height` and the browser reserves its space before it loads. + */ +export function createNgmdImageExtension(sizes: NgmdImageSizes = {}) { + return { + name: 'ngmd-image', + level: 'block' as const, + start(src: string) { + return src.match(/^\s*${escapeHtml(token.caption)}` : ''; + return `${caption}`; + }, + }; +} + +export const ngmdImageExtension = createNgmdImageExtension(); diff --git a/apps/docs/src/marked-extensions/runtime.ts b/apps/docs/src/marked-extensions/runtime.ts index bf8db3b1..e96bd132 100644 --- a/apps/docs/src/marked-extensions/runtime.ts +++ b/apps/docs/src/marked-extensions/runtime.ts @@ -1,12 +1,17 @@ import type {MarkedExtension} from 'marked'; import {ngmdVideoExtension} from './ngmd-video.ts'; -import {ngmdImageExtension} from './ngmd-image.ts'; +import {createNgmdImageExtension, type NgmdImageSizes} from './ngmd-image.ts'; import {ngmdKeywordsExtension} from './ngmd-keywords.ts'; -export const ngmdRuntimeExtensions: MarkedExtension[] = [ - { - extensions: [ngmdVideoExtension, ngmdImageExtension], - }, - ngmdKeywordsExtension, - {hooks: {postprocess: (html: string) => html.replace(//g, '')}}, -]; +/** The extensions with no Node deps; `imageSizes` sizes the `` images. */ +export function createNgmdRuntimeExtensions(imageSizes: NgmdImageSizes = {}): MarkedExtension[] { + return [ + { + extensions: [ngmdVideoExtension, createNgmdImageExtension(imageSizes)], + }, + ngmdKeywordsExtension, + {hooks: {postprocess: (html: string) => html.replace(//g, '')}}, + ]; +} + +export const ngmdRuntimeExtensions = createNgmdRuntimeExtensions(); diff --git a/apps/docs/src/vite-env.d.ts b/apps/docs/src/vite-env.d.ts index 02986967..5a0f136f 100644 --- a/apps/docs/src/vite-env.d.ts +++ b/apps/docs/src/vite-env.d.ts @@ -4,6 +4,8 @@ declare module 'virtual:ngmd/page-meta' { export interface PageMeta { editUrl: string; lastUpdated: string; + description?: string; + noIndex?: boolean; } export const pageMeta: Record; } diff --git a/apps/docs/wrangler.jsonc b/apps/docs/wrangler.jsonc index 3b09d343..44f69c42 100644 --- a/apps/docs/wrangler.jsonc +++ b/apps/docs/wrangler.jsonc @@ -5,6 +5,9 @@ // The site is fully prerendered, so it is served as static assets only. "assets": { "directory": "../../dist/apps/docs/analog/public", + // Serve `/guide` from `guide/index.html` and redirect `/guide/` to it, so + // the URLs in links and the sitemap answer without a redirect. + "html_handling": "drop-trailing-slash", "not_found_handling": "404-page", }, }