From df75293fb5a7ef4eea033bd9891644b9b2cebb6f Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 19 May 2026 10:19:30 +0530 Subject: [PATCH 1/8] feat: add remark plugin to collect image URLs from MDX content Extracts all image sources (markdown, HTML, JSX, HAST) into a file.data.images array for downstream export. Runs after remark-resolve-images so URLs are already resolved. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/remark-collect-images.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/chronicle/src/lib/remark-collect-images.ts diff --git a/packages/chronicle/src/lib/remark-collect-images.ts b/packages/chronicle/src/lib/remark-collect-images.ts new file mode 100644 index 0000000..564c3a8 --- /dev/null +++ b/packages/chronicle/src/lib/remark-collect-images.ts @@ -0,0 +1,53 @@ +import { visit } from 'unist-util-visit' +import type { Plugin } from 'unified' +import type { Image, Html } from 'mdast' +import type { Element } from 'hast' +import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx' + +const remarkCollectImages: Plugin = () => { + return (tree, file) => { + const images: string[] = [] + const seen = new Set() + + function add(src: string) { + if (!src || seen.has(src)) return + if (/^data:/i.test(src)) return + seen.add(src) + images.push(src) + } + + visit(tree, 'image', (node: Image) => { + add(node.url) + }) + + visit(tree, 'html', (node: Html) => { + const re = /]*\bsrc=["']([^"']+)["']/gi + let match: RegExpExecArray | null + while ((match = re.exec(node.value))) { + add(match[1]) + } + }) + + visit(tree, (node) => { + if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return + const jsx = node as MdxJsxFlowElement | MdxJsxTextElement + if (jsx.name !== 'img') return + const srcAttr = jsx.attributes.find( + (a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src' + ) + if (srcAttr?.value && typeof srcAttr.value === 'string') { + add(srcAttr.value) + } + }) + + visit(tree, 'element', (node: Element) => { + if (node.tagName !== 'img') return + const src = node.properties?.src + if (typeof src === 'string') add(src) + }) + + file.data.images = images + } +} + +export default remarkCollectImages From dc9417b32874da00400e2180f09f3ed43cd4574c Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 19 May 2026 10:20:28 +0530 Subject: [PATCH 2/8] feat: wire image collection into build pipeline Register remarkCollectImages after remarkResolveImages in Vite MDX config, export 'images' from MDX modules, and add imagesGlob + getPageImages helper in source.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/source.ts | 22 ++++++++++++++------ packages/chronicle/src/server/vite-config.ts | 4 +++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 9819745..47dca7b 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -37,6 +37,11 @@ const readingTimeGlob: Record = import.meta.glob( + '../../.content/**/*.{mdx,md}', + { eager: true, import: 'images' } +); + const metaGlob: Record> = import.meta.glob( '../../.content/**/meta.json', { eager: true } @@ -54,10 +59,11 @@ function buildFiles() { const relativePath = originalPath.replace(/readme\.(mdx?)$/i, 'index.$1'); const rt = readingTimeGlob[key]; const _readingTime = rt?.minutes != null ? Math.max(1, Math.round(rt.minutes)) : undefined; + const _images = imagesGlob[key] ?? []; files.push({ type: 'page', path: relativePath, - data: { ...data, _readingTime, _relativePath: relativePath, _originalPath: originalPath } + data: { ...data, _readingTime, _images, _relativePath: relativePath, _originalPath: originalPath } }); } @@ -285,6 +291,10 @@ export function getOriginalPath(page: { data: unknown }): string { return ((page.data as Record)._originalPath as string) ?? ''; } +export function getPageImages(page: { data: unknown }): string[] { + return ((page.data as Record)._images as string[]) ?? []; +} + export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> { const originalPath = getOriginalPath(page); if (!originalPath) return { headings: '', body: '' }; @@ -321,21 +331,21 @@ interface ReadingTime { time: number; } -const ssrModules = import.meta.glob<{ default?: MDXContent; toc?: TableOfContents; readingTime?: ReadingTime }>( +const ssrModules = import.meta.glob<{ default?: MDXContent; toc?: TableOfContents; readingTime?: ReadingTime; images?: string[] }>( '../../.content/**/*.{mdx,md}' ); export async function loadPageModule( relativePath: string -): Promise<{ default: MDXContent | null; toc: TableOfContents; _readingTime?: number }> { - if (!relativePath || relativePath.includes('..')) return { default: null, toc: [] }; +): Promise<{ default: MDXContent | null; toc: TableOfContents; _readingTime?: number; images: string[] }> { + if (!relativePath || relativePath.includes('..')) return { default: null, toc: [], images: [] }; const withoutExt = relativePath.replace(/\.(mdx|md)$/, ''); const key = relativePath.endsWith('.md') ? `../../.content/${withoutExt}.md` : `../../.content/${withoutExt}.mdx`; const loader = ssrModules[key]; - if (!loader) return { default: null, toc: [] }; + if (!loader) return { default: null, toc: [], images: [] }; const mod = await loader(); const minutes = mod.readingTime?.minutes; - return { default: mod.default ?? null, toc: mod.toc ?? [], _readingTime: minutes != null ? Math.max(1, Math.round(minutes)) : undefined }; + return { default: mod.default ?? null, toc: mod.toc ?? [], _readingTime: minutes != null ? Math.max(1, Math.round(minutes)) : undefined, images: mod.images ?? [] }; } diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 18689b0..de5ce15 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import remarkDirective from 'remark-directive'; import { type InlineConfig } from 'vite'; import remarkResolveImages from '../lib/remark-resolve-images'; +import remarkCollectImages from '../lib/remark-collect-images'; import remarkResolveLinks from '../lib/remark-resolve-links'; import remarkReadingTime from 'remark-reading-time'; import remarkUnusedDirectives from '../lib/remark-unused-directives'; @@ -72,7 +73,7 @@ export async function createViteConfig( default: defineFumadocsConfig({ mdxOptions: { remarkImageOptions: false, - valueToExport: ['readingTime'], + valueToExport: ['readingTime', 'images'], remarkPlugins: [ remarkDirective, [remarkDirectiveAdmonition, { @@ -95,6 +96,7 @@ export async function createViteConfig( remarkUnusedDirectives, remarkResolveLinks, remarkResolveImages, + remarkCollectImages, remarkMdxMermaid, remarkReadingTime, ], From f88844f55ef9d6fd0867e8ee5c2bad8fca662f41 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 19 May 2026 10:20:41 +0530 Subject: [PATCH 3/8] feat: return images array in /api/page response Includes all resolved image URLs from page content in the API response for downstream preloading. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/api/page.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/server/api/page.ts b/packages/chronicle/src/server/api/page.ts index 7c5ba9b..7f958b4 100644 --- a/packages/chronicle/src/server/api/page.ts +++ b/packages/chronicle/src/server/api/page.ts @@ -1,5 +1,5 @@ import { defineHandler, HTTPError } from 'nitro'; -import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source'; +import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages, isDraft } from '@/lib/source'; export default defineHandler(async event => { const slugParam = event.url.searchParams.get('slug') ?? ''; @@ -16,6 +16,7 @@ export default defineHandler(async event => { frontmatter: extractFrontmatter(page, slug[slug.length - 1]), relativePath: getRelativePath(page), originalPath: getOriginalPath(page), + images: getPageImages(page), prev: nav.prev, next: nav.next, }); From d8d57dd1b39612f6ec6f3feba1a35b592c48c409 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 19 May 2026 10:20:56 +0530 Subject: [PATCH 4/8] feat: inject image preload links in SSR HTML head Adds tags for all page images during server-side rendering for faster initial page load. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/entry-server.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index d8220bb..4cd1938 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -79,6 +79,7 @@ export default { const relativePath = page ? getRelativePath(page) : null; const originalPath = page ? getOriginalPath(page) : null; const mdxModule = (originalPath || relativePath) ? await loadPageModule(originalPath || relativePath!) : null; + const pageImages = mdxModule?.images ?? []; const pageData = page ? { @@ -125,6 +126,9 @@ export default { {assets.js.map((attr: { href: string }) => ( ))} + {pageImages.map((src: string) => ( + + ))}