Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions portal-template/remark-jsx-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import path from 'node:path';

/** An mdast node, as far as an `<img>` 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 `<img>` 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 `<img>`'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 } }) }
};
}
3 changes: 2 additions & 1 deletion portal-template/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]
}
}
}),
Expand Down
37 changes: 27 additions & 10 deletions src/types/portal/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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. */
Expand All @@ -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[];
}
Expand Down Expand Up @@ -128,12 +130,7 @@ async function imagesIn(body: string, isMdx: boolean): Promise<PageImage[]> {

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 });
Expand All @@ -144,13 +141,33 @@ async function imagesIn(body: string, isMdx: boolean): Promise<PageImage[]> {
return images;
}

function addressOf(node: MarkdownNode, definitions: Map<string, string>): 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 `<img>`'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<PageImage, 'line'> | 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 {
Expand Down
2 changes: 1 addition & 1 deletion src/types/portal/portal-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;

/** 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;
Expand Down
17 changes: 17 additions & 0 deletions test/e2e/portal-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ const stylesheetOf = (output: DirectoryPath) => {
expect(exists('images/logo.png')).to.be.true;
});

// Hosted at the root too, an `<img>` is bundled (remark-jsx-images.ts); a file this small is inlined.
it("bundles an <img>'s file", () => {
expect(read('authentication/index.html')).to.match(/<img[^>]* 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;
});
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -844,15 +850,26 @@ const stylesheetOf = (output: DirectoryPath) => {
expect(read('authentication/index.html')).to.match(/<img[^>]* src="\/api\/assets\/diagram[^"]*\.png"/);
});

// `remarkImage` bundles Markdown images alone; the template bundles these (remark-jsx-images.ts).
it('bundles an <img> in an .mdx page, from the static directory or beside its page', () => {
const page = read('figures/index.html');

expect(page).to.match(/<img[^>]* src="\/api\/assets\/diagram[^"]*\.png"[^>]* width="320"/);
expect(page).to.match(/<img[^>]* src="\/api\/assets\/flow[^"]*\.png"/);
});

// Fumadocs swapped a bundled image for its import's name, `<img src="__img0" />` (fuma-nama/fumadocs#3662).
it('keeps the images as written in the Markdown twins and llms-full.txt', () => {
const full = read('llms-full.txt');

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('<img src="/images/diagram.png" width="320"');
expect(read('figures.md')).to.contain('<img src="./flow.png" alt="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.
Expand Down
152 changes: 152 additions & 0 deletions test/portal-template/remark-jsx-images.test.ts
Original file line number Diff line number Diff line change
@@ -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 <img> 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('<img src="./flow.png" width="200" alt="Flow" />\n\n<img src="/images/logo.png" />\n')).to.equal(
'import __jsxImg0 from "./flow.png"\n\nimport __jsxImg1 from "../../static/images/logo.png"\n\n' +
'<img src={__jsxImg0} width="200" alt="Flow" />\n\n<img src={__jsxImg1} />\n'
);
});

it('imports an <img> inside a line of text, and one inside a component', () => {
expect(
transformed('Inline <img src="../shared.png" /> here.\n\n<Card>\n <img src="diagram.png" />\n</Card>\n')
).to.equal(
'import __jsxImg0 from "../shared.png"\n\nimport __jsxImg1 from "./diagram.png"\n\n' +
'Inline <img src={__jsxImg0} /> here.\n\n<Card>\n <img src={__jsxImg1} />\n</Card>\n'
);
});

it('reads the address without the spaces around it or its query or fragment, and decoded', () => {
expect(transformed('<img src=" ./my%20flow.png?v=2#top " />\n')).to.equal(
'import __jsxImg0 from "./my flow.png"\n\n<img src={__jsxImg0} />\n'
);
});

const namingNoFile = [
'<img src="https://example.com/a.png" />',
'<img src="//cdn.example.com/a.png" />',
'<img src="data:image/png;base64,AAAA" />',
'<img src="" />',
'<img src="images/" />',
'<img src={logo} />',
'<img alt="No address" />'
];

it('leaves an address that names no file as written, and an <img> 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 = '<Img src="./a.png" />\n\n<image src="./a.png" />\n\n<video src="./a.mp4" />\n';

expect(transformed(mdx)).to.equal(mdx);
});

it('leaves an address in the static directory as written when there is none', () => {
expect(transformed('<img src="/images/logo.png" />\n\n<img src="./flow.png" />\n', false)).to.equal(
'import __jsxImg0 from "./flow.png"\n\n<img src="/images/logo.png" />\n\n<img src={__jsxImg0} />\n'
);
});

// The step runs on every page of the author's; in a `.md` page a tag is raw HTML, which the build leaves out.
it("leaves a .md page's <img> as written", () => {
const markdown = remark();
const tree = markdown.parse('<img src="./flow.png">\n');
remarkJsxImages({ publicDir: staticDirectory })(tree, { dirname: pageDirectory });

expect(markdown.stringify(tree)).to.equal('<img src="./flow.png">\n');
});

it('leaves a page it has no path for as written', () => {
const tree = processor.parse('<img src="./flow.png" />\n');
remarkJsxImages({ publicDir: staticDirectory })(tree, {});

expect(processor.stringify(tree)).to.equal('<img src="./flow.png" />\n');
});

// MDX compiles these programs, not the text the tests above read.
it('gives MDX the program of each import and of the address', () => {
const tree = processor.parse('<img src="./flow.png" />\n');
remarkJsxImages({ publicDir: staticDirectory })(tree, { dirname: pageDirectory });
const [esm, image] = tree.children as unknown as [
{ data: { estree: unknown } },
{ attributes: [{ value: { data: { estree: unknown } } }] }
];

expect(esm.data.estree).to.deep.equal({
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ImportDeclaration',
source: { type: 'Literal', value: './flow.png' },
specifiers: [{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name: '__jsxImg0' } }]
}
]
});
expect(image.attributes[0].value.data.estree).to.deep.equal({
type: 'Program',
sourceType: 'module',
body: [{ type: 'ExpressionStatement', expression: { type: 'Identifier', name: '__jsxImg0' } }]
});
});

// The CLI reports a missing file before a build that would otherwise fail over its import, naming no page.
it('imports exactly the files the CLI checks', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the test that would catch the two copies drifting, but its input avoids the edge cases where they're most likely to drift. Consider adding:

  • an address decodeURI rejects (./a%E0.png);
  • one ending in /;
  • data: and // addresses;
  • a run with publicDir: false.

Reusing the inputs of the "leaves an address that names no file" test would do most of it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ./h%E0.png, the "names no file" list (now shared by both tests), and a run with publicDir: false, where only the files beside the page are imported.

const mdx =
[
'---\ntitle: Images\n---',
'<img src="/images/a%20b.png?v=1" />',
'Inline <img src="./c.png#top" /> here.',
'<Card>\n <img src="../d.png" />\n</Card>',
'<img src=" ./g.png " />',
'<img src="./h%E0.png" />',
...namingNoFile
].join('\n\n') + '\n';
const checked = (await parsePage(mdx, new FileName('page.mdx'), 'content/guides/page.mdx')).images;
const imported = (publicDir: string | false) =>
[...transformed(mdx, publicDir).matchAll(/^import __jsxImg\d+ from "(.+)"$/gm)].map(([, source]) =>
path.join(pageDirectory, source)
);

expect(imported(staticDirectory)).to.deep.equal(
checked.map(({ from, path: file }) => path.join(from === 'static' ? staticDirectory : pageDirectory, file))
);
// Without a static directory the CLI refuses a `/…` image as missing, so the two need to agree only on the rest.
expect(imported(false)).to.deep.equal(
checked.flatMap(({ from, path: file }) => (from === 'page' ? [path.join(pageDirectory, file)] : []))
);
});

it('imports a static file on another drive by its own path, which static-images.ts resolves', function () {
if (process.platform !== 'win32') this.skip();

const tree = processor.parse('<img src="/images/logo.png" />\n');
remarkJsxImages({ publicDir: 'D:\\static' })(tree, { dirname: 'C:\\portal\\content' });

expect(processor.stringify(tree)).to.equal(
'import __jsxImg0 from "./D:/static/images/logo.png"\n\n<img src={__jsxImg0} />\n'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ description: How requests are authorised.
Authentication for the fixture.

<p className="tracking-[0.3125em]">Spaced out by a class only this page uses.</p>

<img src="./mark.svg" alt="The fixture's mark" width="48" />
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading