diff --git a/portal-template/remark-jsx-images.ts b/portal-template/remark-jsx-images.ts new file mode 100644 index 000000000..225560f4a --- /dev/null +++ b/portal-template/remark-jsx-images.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; + +/** An mdast node, as far as an `` written in an `.mdx` page and the import added for its file go. */ +interface MarkdownNode { + type: string; + name?: string | null; + attributes?: JsxAttribute[]; + value?: unknown; + data?: unknown; + children?: MarkdownNode[]; +} + +interface JsxAttribute { + type: string; + name?: string; + value?: unknown; +} + +// A scheme, a drive letter or a network path: none of them names a file the build imports. +const NOT_A_FILE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i; + +const estreeProgram = (statement: object) => ({ type: 'Program', sourceType: 'module', body: [statement] }); + +/** Bundles the file an `` names, as `remarkImage` does for Markdown images alone. page.ts checks the same. */ +export function remarkJsxImages({ publicDir }: { publicDir: string | false }) { + return (tree: MarkdownNode, { dirname }: { dirname?: string }) => { + if (dirname === undefined) return; + const imports: MarkdownNode[] = []; + for (const { attribute, address } of imageSources(tree)) { + const file = fileOf(address, publicDir, dirname); + if (file === null) continue; + // As `remarkImage` writes it, so a file on another drive is `./C:/…`, which static-images.ts resolves. + const relative = path.relative(dirname, file).replaceAll(path.sep, '/'); + const name = `__jsxImg${imports.length}`; + imports.push(importOf(name, relative.startsWith('../') ? relative : `./${relative}`)); + attribute.value = expressionOf(name); + } + tree.children?.unshift(...imports); + }; +} + +function* imageSources(node: MarkdownNode): Generator<{ attribute: JsxAttribute; address: string }> { + if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && node.name === 'img') { + const src = node.attributes?.find(({ type, name }) => type === 'mdxJsxAttribute' && name === 'src'); + // A browser drops the spaces around an ``'s address; `remarkImage` keeps a Markdown image's. + if (typeof src?.value === 'string') yield { attribute: src, address: src.value.trim() }; + } + for (const child of node.children ?? []) yield* imageSources(child); +} + +function fileOf(url: string, publicDir: string | false, dirname: string): string | null { + const address = url.split(/[?#]/, 1)[0]; + if (address.length === 0 || address.endsWith('/') || NOT_A_FILE.test(address)) return null; + const decoded = decodedAddress(address); + if (!decoded.startsWith('/')) return path.join(dirname, decoded); + return publicDir === false ? null : path.join(publicDir, decoded); +} + +function decodedAddress(address: string): string { + try { + return decodeURI(address); + } catch { + return address; + } +} + +function importOf(name: string, source: string): MarkdownNode { + return { + type: 'mdxjsEsm', + value: `import ${name} from ${JSON.stringify(source)}`, + data: { + estree: estreeProgram({ + type: 'ImportDeclaration', + source: { type: 'Literal', value: source }, + specifiers: [{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name } }] + }) + } + }; +} + +function expressionOf(name: string): MarkdownNode { + return { + type: 'mdxJsxAttributeValueExpression', + value: name, + data: { estree: estreeProgram({ type: 'ExpressionStatement', expression: { type: 'Identifier', name } }) } + }; +} diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 57b4643cd..1151c6ff5 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -9,6 +9,7 @@ import { downloads } from './downloads.ts'; import { generatedPagesReload } from './generated-pages-reload.ts'; import { readBuildPaths, readPortalIdentity } from './portal-config.ts'; import { prerenderPages } from './prerender-pages.ts'; +import { remarkJsxImages } from './remark-jsx-images.ts'; import { specReload } from './spec-reload.ts'; import { remarkImageReferences } from './src/lib/remark-image-references.ts'; import { staticFunctionsBase } from './static-functions-base.ts'; @@ -36,7 +37,7 @@ export default defineConfig(async ({ command, isPreview }) => { mdxOptions: { remarkImageOptions: { publicDir: publicDir || undefined, external: false }, // A function, so it runs ahead of the preset's own plugins, `remarkImage` among them. - remarkPlugins: (plugins) => [remarkImageReferences, ...plugins] + remarkPlugins: (plugins) => [remarkImageReferences, [remarkJsxImages, { publicDir }], ...plugins] } } }), diff --git a/src/types/portal/page.ts b/src/types/portal/page.ts index 7e423b4b3..bb0bb21c8 100644 --- a/src/types/portal/page.ts +++ b/src/types/portal/page.ts @@ -11,9 +11,9 @@ export interface PageFrontMatter { title: string; } -/** A Markdown image the build imports, where it looks for the file, and the path it looks up there. */ +/** An image the build imports, where it looks for the file, and the path it looks up there. */ export interface PageImage { - /** As the page writes it. */ + /** As the page writes it: a Markdown image's address, or the `src` of an `` in an `.mdx` page, trimmed. */ url: string; line: number; /** An image written as `/…` is read from the static directory, any other from beside its page. */ @@ -36,6 +36,8 @@ interface MarkdownNode { type: string; url?: string; identifier?: string; + name?: string | null; + attributes?: { type: string; name?: string; value?: unknown }[]; position?: { start: { line: number } }; children?: MarkdownNode[]; } @@ -128,12 +130,7 @@ async function imagesIn(body: string, isMdx: boolean): Promise { const images: PageImage[] = []; const visit = (node: MarkdownNode) => { - const url = - node.type === 'image' - ? node.url - : node.type === 'imageReference' - ? definitions.get(node.identifier ?? '') - : undefined; + const url = addressOf(node, definitions); const image = url !== undefined ? located(url) : null; if (image !== null) { images.push({ ...image, line: node.position?.start.line ?? 0 }); @@ -144,13 +141,33 @@ async function imagesIn(body: string, isMdx: boolean): Promise { return images; } +function addressOf(node: MarkdownNode, definitions: Map): string | undefined { + switch (node.type) { + case 'image': + return node.url; + case 'imageReference': + return definitions.get(node.identifier ?? ''); + // remark-jsx-images.ts imports these too. + case 'mdxJsxFlowElement': + case 'mdxJsxTextElement': { + const src = node.attributes?.find(({ type, name }) => type === 'mdxJsxAttribute' && name === 'src')?.value; + // A browser drops the spaces around an ``'s address; `remarkImage` keeps a Markdown image's. + return node.name === 'img' && typeof src === 'string' ? src.trim() : undefined; + } + default: + return undefined; + } +} + function located(url: string): Omit | null { const address = url.split(/[?#]/, 1)[0]; if (address.length === 0 || address.endsWith('/') || NOT_A_FILE.test(address)) { return null; } - const path = decodedAddress(address); - return path.startsWith('/') ? { url, from: 'static', path: path.replace(/^\/+/, '') } : { url, from: 'page', path }; + const decoded = decodedAddress(address); + return decoded.startsWith('/') + ? { url, from: 'static', path: decoded.replace(/^\/+/, '') } + : { url, from: 'page', path: decoded }; } function decodedAddress(address: string): string { diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index 064b524f8..2209a6d7d 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -92,7 +92,7 @@ export interface ReservedAddressPage { /** Pages of the user's that would be served at one address, which only one of them can have. */ export type SharedAddress = Shared; -/** A page's Markdown image that the build would import and not find, which fails the whole build. */ +/** A page's image that the build would import and not find, which fails the whole build. */ export interface MissingImage { page: FilePath; line: number; diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 9e6fc12a6..d8d8d04b7 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -212,6 +212,11 @@ const stylesheetOf = (output: DirectoryPath) => { expect(exists('images/logo.png')).to.be.true; }); + // Hosted at the root too, an `` is bundled (remark-jsx-images.ts); a file this small is inlined. + it("bundles an 's file", () => { + expect(read('authentication/index.html')).to.match(/]* src="data:image\/svg\+xml,[^"]+"[^>]* width="48"/); + }); + it('writes the not-found page static hosts serve for unknown paths', () => { expect(exists('404.html')).to.be.true; }); @@ -814,6 +819,7 @@ const stylesheetOf = (output: DirectoryPath) => { for (const page of [ 'index.html', 'authentication/index.html', + 'figures/index.html', 'api/apimatic-calculator/simple-calculator/Calculate/index.html', 'sdks/index.html', 'sdks/typescript/index.html', @@ -844,6 +850,14 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read('authentication/index.html')).to.match(/]* src="\/api\/assets\/diagram[^"]*\.png"/); }); + // `remarkImage` bundles Markdown images alone; the template bundles these (remark-jsx-images.ts). + it('bundles an in an .mdx page, from the static directory or beside its page', () => { + const page = read('figures/index.html'); + + expect(page).to.match(/]* src="\/api\/assets\/diagram[^"]*\.png"[^>]* width="320"/); + expect(page).to.match(/]* src="\/api\/assets\/flow[^"]*\.png"/); + }); + // Fumadocs swapped a bundled image for its import's name, `` (fuma-nama/fumadocs#3662). it('keeps the images as written in the Markdown twins and llms-full.txt', () => { const full = read('llms-full.txt'); @@ -851,8 +865,11 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read('index.md')).to.contain('![How a calculation flows](/images/diagram.png)'); expect(read('authentication.md')).to.contain('![The flow, by reference][flow]'); expect(read('authentication.md')).to.contain('[flow]: /images/diagram.png'); + expect(read('figures.md')).to.contain('The steps'); expect(full).to.contain('![How a calculation flows](/images/diagram.png)'); expect(full).to.not.contain('__img'); + expect(full).to.not.contain('__jsxImg'); }); // Fumadocs' own renderer fails the whole page over an image in a description; `ApiMarkdown` stands in for it. diff --git a/test/portal-template/remark-jsx-images.test.ts b/test/portal-template/remark-jsx-images.test.ts new file mode 100644 index 000000000..25ee4818d --- /dev/null +++ b/test/portal-template/remark-jsx-images.test.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import { expect } from 'chai'; +import { remark } from 'remark'; +import remarkMdx from 'remark-mdx'; +import { FileName } from '../../src/types/file/fileName'; +import { parsePage } from '../../src/types/portal/page'; +import { remarkJsxImages } from '../../portal-template/remark-jsx-images'; + +describe('the tags of the .mdx content pages', () => { + const project = path.resolve('/portal'); + const staticDirectory = path.join(project, 'static'); + const pageDirectory = path.join(project, 'content', 'guides'); + const processor = remark().use(remarkMdx); + + /** The page once the step has run, written back as MDX, the imports it adds at the top. */ + const transformed = (mdx: string, publicDir: string | false = staticDirectory): string => { + const tree = processor.parse(mdx); + remarkJsxImages({ publicDir })(tree, { dirname: pageDirectory }); + return processor.stringify(tree); + }; + + it('imports the file beside the page or in the static directory, keeping the other attributes', () => { + expect(transformed('Flow\n\n\n')).to.equal( + 'import __jsxImg0 from "./flow.png"\n\nimport __jsxImg1 from "../../static/images/logo.png"\n\n' + + 'Flow\n\n\n' + ); + }); + + it('imports an inside a line of text, and one inside a component', () => { + expect( + transformed('Inline here.\n\n\n \n\n') + ).to.equal( + 'import __jsxImg0 from "../shared.png"\n\nimport __jsxImg1 from "./diagram.png"\n\n' + + 'Inline here.\n\n\n \n\n' + ); + }); + + it('reads the address without the spaces around it or its query or fragment, and decoded', () => { + expect(transformed('\n')).to.equal( + 'import __jsxImg0 from "./my flow.png"\n\n\n' + ); + }); + + const namingNoFile = [ + '', + '', + '', + '', + '', + '', + 'No address' + ]; + + it('leaves an address that names no file as written, and an without one', () => { + const mdx = namingNoFile.join('\n\n') + '\n'; + + expect(transformed(mdx)).to.equal(mdx); + }); + + it('leaves other tags as written, a component named Img among them', () => { + const mdx = '\n\n\n\n