diff --git a/packages/chronicle/src/lib/mdx-utils.ts b/packages/chronicle/src/lib/mdx-utils.ts new file mode 100644 index 0000000..d88dc7a --- /dev/null +++ b/packages/chronicle/src/lib/mdx-utils.ts @@ -0,0 +1,4 @@ +export const MdxNodeType = { + JsxFlow: 'mdxJsxFlowElement', + JsxText: 'mdxJsxTextElement', +} as const diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index f49180d..0464a88 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -110,6 +110,7 @@ export function PageProvider({ frontmatter: Frontmatter; relativePath: string; originalPath?: string; + images?: string[]; prev?: PageNavLink | null; next?: PageNavLink | null; } @@ -132,6 +133,12 @@ export function PageProvider({ try { const data = await fetchPageData(slug); if (cancelled.current) return; + if (data.images?.length) { + for (const src of data.images) { + const img = new Image(); + img.src = src; + } + } const { content, toc } = await loadMdx(data.originalPath || data.relativePath); if (cancelled.current) return; setErrorStatus(null); diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts index eceb4fe..d67fbff 100644 --- a/packages/chronicle/src/lib/remark-resolve-images.ts +++ b/packages/chronicle/src/lib/remark-resolve-images.ts @@ -4,6 +4,7 @@ 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' +import { MdxNodeType } from './mdx-utils' function resolveUrl(src: string, dir: string): string { if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src @@ -26,25 +27,40 @@ const remarkResolveImages: Plugin = () => { const relative = filePath.slice(contentIdx + '/content/'.length) const dir = path.posix.dirname(relative) + const seen = new Set() + const images: string[] = [] + + function collect(src: string) { + if (!src || seen.has(src) || /^data:/i.test(src)) return + seen.add(src) + images.push(src) + } + visit(tree, 'image', (node: Image) => { if (!node.url) return node.url = resolveUrl(node.url, dir) + collect(node.url) }) visit(tree, 'html', (node: Html) => { node.value = node.value.replace( /(]*\bsrc=["'])([^"']+)(["'])/gi, - (_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}` + (_, before, src, after) => { + const resolved = resolveUrl(src, dir) + collect(resolved) + return `${before}${resolved}${after}` + } ) }) visit(tree, (node) => { - if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return + if (node.type !== MdxNodeType.JsxFlow && node.type !== MdxNodeType.JsxText) 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') return srcAttr.value = resolveUrl(srcAttr.value, dir) + collect(srcAttr.value) }) visit(tree, 'element', (node: Element) => { @@ -52,7 +68,10 @@ const remarkResolveImages: Plugin = () => { const src = node.properties?.src if (typeof src !== 'string') return node.properties.src = resolveUrl(src, dir) + collect(node.properties.src as string) }) + + file.data.images = images } } diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 9819745..7311df2 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: '' }; 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, }); diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index d8220bb..a7509c4 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -9,7 +9,7 @@ import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { PageProvider } from '@/lib/page-context'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; -import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source'; +import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages, isDraft } from '@/lib/source'; import { getFirstApiUrl } from '@/lib/api-routes'; import { StatusCodes } from 'http-status-codes'; import { resolveDocsRedirect } from '@/lib/tree-utils'; @@ -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 = page ? getPageImages(page) : []; const pageData = page ? { @@ -125,6 +126,9 @@ export default { {assets.js.map((attr: { href: string }) => ( ))} + {pageImages.map((src: string) => ( + + ))}