From fa727ef83f1f25d0c1e62638b362af06f9b81b87 Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Mon, 22 Jun 2026 14:07:29 +0300 Subject: [PATCH 01/26] feat(utils): add canonical slugify for tag slugs --- src/utils/slug.test.mjs | 15 +++++++++++++++ src/utils/slug.ts | 8 ++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/utils/slug.test.mjs create mode 100644 src/utils/slug.ts diff --git a/src/utils/slug.test.mjs b/src/utils/slug.test.mjs new file mode 100644 index 0000000..80bad67 --- /dev/null +++ b/src/utils/slug.test.mjs @@ -0,0 +1,15 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { slugify } from './slug.ts'; + +test('lowercases and hyphenates', () => { + assert.equal(slugify('Home Assistant'), 'home-assistant'); + assert.equal(slugify('3D Printing'), '3d-printing'); + assert.equal(slugify('COVID'), 'covid'); + assert.equal(slugify('harness engineering'), 'harness-engineering'); +}); + +test('collapses runs and trims', () => { + assert.equal(slugify(' a / b '), 'a-b'); + assert.equal(slugify('already-clean'), 'already-clean'); +}); diff --git a/src/utils/slug.ts b/src/utils/slug.ts new file mode 100644 index 0000000..d4d9083 --- /dev/null +++ b/src/utils/slug.ts @@ -0,0 +1,8 @@ +/** Canonical slugify used for tag URLs (and anywhere a name→slug is needed). */ +export function slugify(input: string): string { + return input + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} From 1d8c6af6cae7bff1259f9d032177996f7828bbb7 Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Mon, 22 Jun 2026 14:10:13 +0300 Subject: [PATCH 02/26] feat(remark): image-captions plugin replicating
output --- src/plugins/remark-image-captions.mjs | 70 ++++++++++++++++++++++ src/plugins/remark-image-captions.test.mjs | 35 +++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/plugins/remark-image-captions.mjs create mode 100644 src/plugins/remark-image-captions.test.mjs diff --git a/src/plugins/remark-image-captions.mjs b/src/plugins/remark-image-captions.mjs new file mode 100644 index 0000000..6d57796 --- /dev/null +++ b/src/plugins/remark-image-captions.mjs @@ -0,0 +1,70 @@ +import { visit } from 'unist-util-visit'; + +const WIDE = /^\{wide\}\s*$/; + +/** + * Turn a paragraph that is a lone image (optionally followed by an emphasis + * caption line) into a
+ * matching the legacy
output. The image node is preserved so Astro + * still optimizes the asset. + * + * remark parses `![img](src)\n*caption*` as a single paragraph with the image, + * a "\n" text node, and an emphasis node as siblings. Similarly `{wide}` appears + * as a trailing text node sibling after the image. + */ +export default function remarkImageCaptions() { + return (tree) => { + visit(tree, 'paragraph', (node, index, parent) => { + if (!parent || index == null) return; + const kids = node.children; + + // Find the image node. + const image = kids.find((c) => c.type === 'image'); + if (!image) return; + + // Classify every child: image, whitespace text, {wide} marker, emphasis (caption), or other. + let wide = false; + let caption = null; + let hasOther = false; + + for (const c of kids) { + if (c.type === 'image') continue; + if (c.type === 'text' && !c.value.trim()) continue; // whitespace/newline + if (c.type === 'text' && WIDE.test(c.value.trim())) { + wide = true; + continue; + } + if (c.type === 'emphasis') { + caption = c.children; + continue; + } + hasOther = true; + } + + // If there are non-image, non-whitespace, non-wide, non-emphasis children, skip. + if (hasOther) return; + + // Strip {wide} from image alt if present (alternate placement). + if (typeof image.alt === 'string' && /\{wide\}\s*$/.test(image.alt)) { + wide = true; + image.alt = image.alt.replace(/\{wide\}\s*$/, '').trim(); + } + + const className = ['image-card', caption ? 'has-caption' : '', wide ? 'content-wide' : ''] + .filter(Boolean) + .join(' '); + + const figcaption = caption + ? [{ type: 'figcaption', data: { hName: 'figcaption' }, children: caption }] + : []; + + const figure = { + type: 'figure', + data: { hName: 'figure', hProperties: { className } }, + children: [image, ...figcaption], + }; + + parent.children.splice(index, 1, figure); + }); + }; +} diff --git a/src/plugins/remark-image-captions.test.mjs b/src/plugins/remark-image-captions.test.mjs new file mode 100644 index 0000000..f6c6e37 --- /dev/null +++ b/src/plugins/remark-image-captions.test.mjs @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeStringify from 'rehype-stringify'; +import remarkImageCaptions from './remark-image-captions.mjs'; + +async function render(md) { + const file = await unified() + .use(remarkParse) + .use(remarkImageCaptions) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeStringify, { allowDangerousHtml: true }) + .process(md); + return String(file); +} + +test('plain image with no caption → figure.image-card', async () => { + const html = await render('![a cat](cat.png)\n'); + assert.match(html, /
/); + assert.match(html, /]+alt="a cat"/); + assert.doesNotMatch(html, /has-caption/); +}); + +test('image + emphasis line → figcaption', async () => { + const html = await render('![a cat](cat.png)\n*A sleepy cat*\n'); + assert.match(html, /
/); + assert.match(html, /
A sleepy cat<\/figcaption>/); +}); + +test('{wide} marker → content-wide', async () => { + const html = await render('![diagram](d.png){wide}\n'); + assert.match(html, /
/); +}); From 1c78f6343208591f2403923b298116745ddb5655 Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Mon, 22 Jun 2026 14:18:46 +0300 Subject: [PATCH 03/26] feat(remark): video-embeds plugin replicating