From b4168a6a6efbf31d5bb99ca7338c50d72a0583be Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Tue, 27 Oct 2026 10:42:43 +0500 Subject: [PATCH 01/10] fix(portal): bundle the file an in an .mdx page names Co-Authored-By: Claude Opus 5.5 --- portal-template/src/lib/remark-jsx-images.ts | 77 +++++++++++ portal-template/vite.config.ts | 3 +- .../portal-template/remark-jsx-images.test.ts | 123 ++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 portal-template/src/lib/remark-jsx-images.ts create mode 100644 test/portal-template/remark-jsx-images.test.ts diff --git a/portal-template/src/lib/remark-jsx-images.ts b/portal-template/src/lib/remark-jsx-images.ts new file mode 100644 index 000000000..ea11f2f44 --- /dev/null +++ b/portal-template/src/lib/remark-jsx-images.ts @@ -0,0 +1,77 @@ +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 program = (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[] = []; + const visit = (node: MarkdownNode) => { + const isImage = (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && node.name === 'img'; + const src = isImage ? node.attributes?.find(({ type, name }) => type === 'mdxJsxAttribute' && name === 'src') : undefined; + const file = typeof src?.value === 'string' ? fileOf(src.value, publicDir, dirname) : null; + if (src !== undefined && file !== null) { + const name = `__jsxImg${imports.length}`; + // 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 source = relative.startsWith('../') ? relative : `./${relative}`; + imports.push({ + type: 'mdxjsEsm', + value: `import ${name} from ${JSON.stringify(source)}`, + data: { + estree: program({ + type: 'ImportDeclaration', + source: { type: 'Literal', value: source }, + specifiers: [{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name } }] + }) + } + }); + src.value = { + type: 'mdxJsxAttributeValueExpression', + value: name, + data: { estree: program({ type: 'ExpressionStatement', expression: { type: 'Identifier', name } }) } + }; + } + node.children?.forEach(visit); + }; + visit(tree); + tree.children?.unshift(...imports); + }; +} + +/** Read without its query or fragment and decoded, `/…` from the static directory and any other from beside the page. */ +function fileOf(address: string, publicDir: string | false, dirname: string): string | null { + const written = address.split(/[?#]/, 1)[0]; + if (written.length === 0 || written.endsWith('/') || NOT_A_FILE.test(written)) return null; + const decoded = decodedAddress(written); + 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; + } +} diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 57b4643cd..81076b7ca 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -11,6 +11,7 @@ import { readBuildPaths, readPortalIdentity } from './portal-config.ts'; import { prerenderPages } from './prerender-pages.ts'; import { specReload } from './spec-reload.ts'; import { remarkImageReferences } from './src/lib/remark-image-references.ts'; +import { remarkJsxImages } from './src/lib/remark-jsx-images.ts'; import { staticFunctionsBase } from './static-functions-base.ts'; import { staticImages } from './static-images.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/test/portal-template/remark-jsx-images.test.ts b/test/portal-template/remark-jsx-images.test.ts new file mode 100644 index 000000000..880392696 --- /dev/null +++ b/test/portal-template/remark-jsx-images.test.ts @@ -0,0 +1,123 @@ +import path from 'node:path'; +import { expect } from 'chai'; +import { remark } from 'remark'; +import remarkMdx from 'remark-mdx'; +import { remarkJsxImages } from '../../portal-template/src/lib/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 its query or fragment, and decoded', () => { + expect(transformed('\n')).to.equal( + 'import __jsxImg0 from "./my flow.png"\n\n\n' + ); + }); + + it('leaves an address that names no file as written, and an without one', () => { + const mdx = + [ + '', + '', + '', + '', + '', + '', + 'No address' + ].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