mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 21:27:54 +08:00
feat: markdown images (#673)
This commit is contained in:
@@ -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: ``,
|
||||
});
|
||||
|
||||
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: `` }),
|
||||
).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: `\n\n`,
|
||||
});
|
||||
|
||||
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: `\n\n`,
|
||||
});
|
||||
|
||||
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: `\n\n`,
|
||||
});
|
||||
|
||||
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: `\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" />
|
||||
Reference in New Issue
Block a user