Skip to content

Commit 7b44d3e

Browse files
rsbhclaude
andauthored
feat: extract and preload page images (#87)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: inject image preload links in SSR HTML head Adds <link rel="preload" as="image"> tags for all page images during server-side rendering for faster initial page load. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: prefetch page images on client-side navigation After fetching page data from /api/page, preloads all images via new Image() so they're cached by the browser before MDX renders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract MdxNodeType const to shared mdx-utils module Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: merge image collection into remark-resolve-images Single tree traversal now both resolves and collects image URLs, eliminating the separate remark-collect-images plugin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use getPageImages(page) in SSR instead of loadPageModule Images are already available from the eager imagesGlob via page data, so no need to load them again from ssrModules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 84c4d8a commit 7b44d3e

7 files changed

Lines changed: 51 additions & 6 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const MdxNodeType = {
2+
JsxFlow: 'mdxJsxFlowElement',
3+
JsxText: 'mdxJsxTextElement',
4+
} as const

packages/chronicle/src/lib/page-context.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function PageProvider({
110110
frontmatter: Frontmatter;
111111
relativePath: string;
112112
originalPath?: string;
113+
images?: string[];
113114
prev?: PageNavLink | null;
114115
next?: PageNavLink | null;
115116
}
@@ -132,6 +133,12 @@ export function PageProvider({
132133
try {
133134
const data = await fetchPageData(slug);
134135
if (cancelled.current) return;
136+
if (data.images?.length) {
137+
for (const src of data.images) {
138+
const img = new Image();
139+
img.src = src;
140+
}
141+
}
135142
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
136143
if (cancelled.current) return;
137144
setErrorStatus(null);

packages/chronicle/src/lib/remark-resolve-images.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Plugin } from 'unified'
44
import type { Image, Html } from 'mdast'
55
import type { Element } from 'hast'
66
import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
7+
import { MdxNodeType } from './mdx-utils'
78

89
function resolveUrl(src: string, dir: string): string {
910
if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src
@@ -26,33 +27,51 @@ const remarkResolveImages: Plugin = () => {
2627
const relative = filePath.slice(contentIdx + '/content/'.length)
2728
const dir = path.posix.dirname(relative)
2829

30+
const seen = new Set<string>()
31+
const images: string[] = []
32+
33+
function collect(src: string) {
34+
if (!src || seen.has(src) || /^data:/i.test(src)) return
35+
seen.add(src)
36+
images.push(src)
37+
}
38+
2939
visit(tree, 'image', (node: Image) => {
3040
if (!node.url) return
3141
node.url = resolveUrl(node.url, dir)
42+
collect(node.url)
3243
})
3344

3445
visit(tree, 'html', (node: Html) => {
3546
node.value = node.value.replace(
3647
/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi,
37-
(_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}`
48+
(_, before, src, after) => {
49+
const resolved = resolveUrl(src, dir)
50+
collect(resolved)
51+
return `${before}${resolved}${after}`
52+
}
3853
)
3954
})
4055

4156
visit(tree, (node) => {
42-
if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return
57+
if (node.type !== MdxNodeType.JsxFlow && node.type !== MdxNodeType.JsxText) return
4358
const jsx = node as MdxJsxFlowElement | MdxJsxTextElement
4459
if (jsx.name !== 'img') return
4560
const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src')
4661
if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
4762
srcAttr.value = resolveUrl(srcAttr.value, dir)
63+
collect(srcAttr.value)
4864
})
4965

5066
visit(tree, 'element', (node: Element) => {
5167
if (node.tagName !== 'img') return
5268
const src = node.properties?.src
5369
if (typeof src !== 'string') return
5470
node.properties.src = resolveUrl(src, dir)
71+
collect(node.properties.src as string)
5572
})
73+
74+
file.data.images = images
5675
}
5776
}
5877

packages/chronicle/src/lib/source.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ const readingTimeGlob: Record<string, { text: string; minutes: number; words: nu
3737
{ eager: true, import: 'readingTime' }
3838
);
3939

40+
const imagesGlob: Record<string, string[] | undefined> = import.meta.glob(
41+
'../../.content/**/*.{mdx,md}',
42+
{ eager: true, import: 'images' }
43+
);
44+
4045
const metaGlob: Record<string, Record<string, unknown>> = import.meta.glob(
4146
'../../.content/**/meta.json',
4247
{ eager: true }
@@ -54,10 +59,11 @@ function buildFiles() {
5459
const relativePath = originalPath.replace(/readme\.(mdx?)$/i, 'index.$1');
5560
const rt = readingTimeGlob[key];
5661
const _readingTime = rt?.minutes != null ? Math.max(1, Math.round(rt.minutes)) : undefined;
62+
const _images = imagesGlob[key] ?? [];
5763
files.push({
5864
type: 'page',
5965
path: relativePath,
60-
data: { ...data, _readingTime, _relativePath: relativePath, _originalPath: originalPath }
66+
data: { ...data, _readingTime, _images, _relativePath: relativePath, _originalPath: originalPath }
6167
});
6268
}
6369

@@ -285,6 +291,10 @@ export function getOriginalPath(page: { data: unknown }): string {
285291
return ((page.data as Record<string, unknown>)._originalPath as string) ?? '';
286292
}
287293

294+
export function getPageImages(page: { data: unknown }): string[] {
295+
return ((page.data as Record<string, unknown>)._images as string[]) ?? [];
296+
}
297+
288298
export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> {
289299
const originalPath = getOriginalPath(page);
290300
if (!originalPath) return { headings: '', body: '' };

packages/chronicle/src/server/api/page.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineHandler, HTTPError } from 'nitro';
2-
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source';
2+
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages, isDraft } from '@/lib/source';
33

44
export default defineHandler(async event => {
55
const slugParam = event.url.searchParams.get('slug') ?? '';
@@ -16,6 +16,7 @@ export default defineHandler(async event => {
1616
frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
1717
relativePath: getRelativePath(page),
1818
originalPath: getOriginalPath(page),
19+
images: getPageImages(page),
1920
prev: nav.prev,
2021
next: nav.next,
2122
});

packages/chronicle/src/server/entry-server.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
99
import { loadApiSpecs } from '@/lib/openapi';
1010
import { PageProvider } from '@/lib/page-context';
1111
import { resolveRoute, RouteType } from '@/lib/route-resolver';
12-
import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source';
12+
import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages, isDraft } from '@/lib/source';
1313
import { getFirstApiUrl } from '@/lib/api-routes';
1414
import { StatusCodes } from 'http-status-codes';
1515
import { resolveDocsRedirect } from '@/lib/tree-utils';
@@ -79,6 +79,7 @@ export default {
7979
const relativePath = page ? getRelativePath(page) : null;
8080
const originalPath = page ? getOriginalPath(page) : null;
8181
const mdxModule = (originalPath || relativePath) ? await loadPageModule(originalPath || relativePath!) : null;
82+
const pageImages = page ? getPageImages(page) : [];
8283

8384
const pageData = page
8485
? {
@@ -125,6 +126,9 @@ export default {
125126
{assets.js.map((attr: { href: string }) => (
126127
<link key={attr.href} rel="modulepreload" {...attr} />
127128
))}
129+
{pageImages.map((src: string) => (
130+
<link key={src} rel="preload" as="image" href={src} />
131+
))}
128132
<script type="module" src={assets.entry} />
129133
<script dangerouslySetInnerHTML={{ __html: `window.__PAGE_DATA__ = ${safeJson}` }} />
130134
</head>

packages/chronicle/src/server/vite-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export async function createViteConfig(
7272
default: defineFumadocsConfig({
7373
mdxOptions: {
7474
remarkImageOptions: false,
75-
valueToExport: ['readingTime'],
75+
valueToExport: ['readingTime', 'images'],
7676
remarkPlugins: [
7777
remarkDirective,
7878
[remarkDirectiveAdmonition, {

0 commit comments

Comments
 (0)