feat: markdown images (#673)

This commit is contained in:
Jason Rasmussen
2026-07-30 14:07:29 -04:00
committed by GitHub
parent edf41e04cd
commit b4f7a3d98c
4 changed files with 169 additions and 4 deletions
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
FileWithContent,
FileWithMarkup,
FileWithImages,
FileWithScriptBody,
SvelteMarkdownPreprocess,
svelteMarkdownPreprocess,
@@ -70,6 +70,118 @@ describe(svelteMarkdownPreprocess.name, () => {
});
});
describe('images', () => {
it('should import relative images so vite can optimize them', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `![Alt text](./img/example.webp "Example")`,
});
expect(result).toMatchObject({ code: expect.stringContaining(`import __image_0 from './img/example.webp';`) });
expect(result).toMatchObject({
// eslint-disable-next-line unicorn/no-incorrect-template-string-interpolation
code: expect.stringContaining(`<Markdown.Image src={__image_0} alt="Alt text" title="Example"/>`),
});
});
it('should import images from a parent directory', async () => {
await expect(
svelteMarkdownPreprocess().markup({ filename: 'test.md', content: `![](../img/example.webp)` }),
).resolves.toMatchObject({ code: expect.stringContaining(`import __image_0 from '../img/example.webp';`) });
});
it('should reuse a single import for a repeated image', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `![One](./img/example.webp)\n\n![Two](./img/example.webp)`,
});
expect(result?.code.match(/import __image_0/g)).toHaveLength(1);
expect(result?.code).not.toContain('__image_1');
expect(result?.code.match(/src=\{__image_0}/g)).toHaveLength(2);
});
it('should give each distinct image its own import', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `![One](./img/one.webp)\n\n![Two](./img/two.webp)`,
});
expect(result?.code).toContain(`import __image_0 from './img/one.webp';`);
expect(result?.code).toContain(`import __image_1 from './img/two.webp';`);
});
it('should leave absolute and remote images alone', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `![One](/img/one.webp)\n\n![Two](https://immich.app/two.webp)`,
});
expect(result?.code).not.toContain('import __image_0');
expect(result?.code).toContain(`src="/img/one.webp"`);
expect(result?.code).toContain(`src="https://immich.app/two.webp"`);
});
it('should import relative images in img tags', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `<img src="./img/example.webp" alt="Alt text">`,
});
expect(result?.code).toContain(`import __image_0 from './img/example.webp';`);
// eslint-disable-next-line unicorn/no-incorrect-template-string-interpolation
expect(result?.code).toContain(`<img src={__image_0} alt="Alt text">`);
});
it('should import img tags from a parent directory', async () => {
await expect(
svelteMarkdownPreprocess().markup({ filename: 'test.md', content: `<img src="../img/example.webp">` }),
).resolves.toMatchObject({ code: expect.stringContaining(`import __image_0 from '../img/example.webp';`) });
});
it('should import relative images in img tags with attributes before the src', async () => {
await expect(
svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `<img class="rounded" src="./img/example.webp">`,
}),
// eslint-disable-next-line unicorn/no-incorrect-template-string-interpolation
).resolves.toMatchObject({ code: expect.stringContaining(`<img class="rounded" src={__image_0}>`) });
});
it('should leave absolute and remote img tags alone', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `<img src="/img/one.webp">\n\n<img src="https://immich.app/two.webp">`,
});
expect(result?.code).not.toContain('import __image_0');
expect(result?.code).toContain(`src="/img/one.webp"`);
expect(result?.code).toContain(`src="https://immich.app/two.webp"`);
});
it('should reuse a single import across markdown and img tag references', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `![One](./img/example.webp)\n\n<img src="./img/example.webp">`,
});
expect(result?.code.match(/import __image_0/g)).toHaveLength(1);
expect(result?.code).not.toContain('__image_1');
expect(result?.code.match(/src=\{__image_0}/g)).toHaveLength(2);
});
it('should not treat other tags as images', async () => {
const result = await svelteMarkdownPreprocess().markup({
filename: 'test.md',
content: `<video src="./video/example.webm"></video>`,
});
expect(result?.code).not.toContain('import __image_0');
expect(result?.code).toContain(`src="./video/example.webm"`);
});
});
describe('method overrides', () => {
it('should allow injecting front matter', async () => {
class CustomPlugin extends SvelteMarkdownPreprocess {
@@ -126,7 +238,7 @@ describe(svelteMarkdownPreprocess.name, () => {
it('should allow injecting a layout', async () => {
class CustomPlugin extends SvelteMarkdownPreprocess {
parseLayout(file: FileWithMarkup) {
parseLayout(file: FileWithImages) {
return { ...file, layout: '$lib/layouts/Custom.svelte' };
}
}
@@ -6,11 +6,14 @@ import { markedSvelte } from './markdown.js';
type MaybePromise<T> = Promise<T> | T;
export type MarkdownImage = { name: string; path: string };
export type FileWithContent = { filename: string; content: string };
export type FileWithFrontMatter = { filename: string; attributes: FrontMatterAttributes; body: string };
export type FileWithScriptBody = FileWithFrontMatter & { scriptBody: string };
export type FileWithMarkup = FileWithScriptBody & { markup: string };
export type FileWithLayout = FileWithMarkup & { layout?: string };
export type FileWithImages = FileWithMarkup & { images: MarkdownImage[] };
export type FileWithLayout = FileWithImages & { layout?: string };
export type FileWithSvelte = FileWithLayout & { svelte: string };
export type FrontMatterAttributes = {
@@ -25,6 +28,8 @@ export type SvelteMarkdownPreprocessLayouts = {
};
const SCRIPT_BODY_REGEX = /<script.*>(?<body>(.|\n)*?)<\/script>/;
const MARKDOWN_IMAGE_SRC_REGEX = /(<Markdown\.Image\b[^>]*?\bsrc=)"([^"]*)"/g;
const IMG_SRC_REGEX = /(<img\b[^>]*?\bsrc=)"([^"]*)"/g;
export type SvelteMarkdownPreprocessOptions = {
/** defaults to `['.md', '.mdx']` */
@@ -62,6 +67,7 @@ export class SvelteMarkdownPreprocess {
.then((file) => this.parseFrontMatter(file))
.then((file) => this.parseScriptBody(file))
.then((file) => this.parseMarkdown(file))
.then((file) => this.parseImages(file))
.then((file) => this.parseLayout(file))
.then((file) => this.parseSvelte(file))
.then((file) => ({ code: file.svelte }));
@@ -96,7 +102,31 @@ export class SvelteMarkdownPreprocess {
return { ...file, markup };
}
parseLayout(file: FileWithMarkup): MaybePromise<FileWithLayout> {
parseImages(file: FileWithMarkup): MaybePromise<FileWithImages> {
const images: MarkdownImage[] = [];
let markup = file.markup;
for (const regex of [MARKDOWN_IMAGE_SRC_REGEX, IMG_SRC_REGEX]) {
markup = markup.replaceAll(regex, (match, prefix: string, path: string) => {
if (!path.startsWith('./') && !path.startsWith('../')) {
return match;
}
let image = images.find((item) => item.path === path);
if (!image) {
image = { name: `__image_${images.length}`, path };
images.push(image);
}
return `${prefix}{${image.name}}`;
});
}
return { ...file, markup, images };
}
parseLayout(file: FileWithImages): MaybePromise<FileWithLayout> {
const layoutKey = file.attributes.layout;
const layout = layoutKey ? this.#layouts[layoutKey] : (this.#layouts.default ?? this.#layouts._);
return { ...file, layout };
@@ -113,6 +143,7 @@ export class SvelteMarkdownPreprocess {
return [
` import { Markdown } from '@immich/ui';`,
file.layout ? ` import Layout from '${file.layout}';` : undefined,
...file.images.map((image) => ` import ${image.name} from '${image.path}';`),
];
}
@@ -166,6 +166,21 @@ exports[`markup > should render 'img-attribute-quote.md' correctly 1`] = `
}
`;
exports[`markup > should render 'img-tag.md' correctly 1`] = `
{
"code": "<script>
import { Markdown } from '@immich/ui';
import __image_0 from './img/relative.webp';
import __image_1 from '../img/parent.webp';
</script>
<img src={__image_0} alt="relative" /><Markdown.Space />
<img class="rounded" src={__image_1} alt="parent" /><Markdown.Space />
<img src="/img/absolute.webp" alt="absolute" /><Markdown.Space />
<img src="https://immich.app/remote.webp" alt="remote" />
",
}
`;
exports[`markup > should render 'link.md' correctly 1`] = `
{
"code": "<script>
@@ -0,0 +1,7 @@
<img src="./img/relative.webp" alt="relative" />
<img class="rounded" src="../img/parent.webp" alt="parent" />
<img src="/img/absolute.webp" alt="absolute" />
<img src="https://immich.app/remote.webp" alt="remote" />