mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 21:27:54 +08:00
@@ -1,145 +1,3 @@
|
||||
import fm from 'front-matter';
|
||||
import { Marked } from 'marked';
|
||||
import type { PreprocessorGroup } from 'svelte/compiler';
|
||||
import { markedSvelte } from './marked-svelte.js';
|
||||
|
||||
type MaybePromise<T> = Promise<T> | T;
|
||||
|
||||
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 FileWithSvelte = FileWithLayout & { svelte: string };
|
||||
|
||||
export type FrontMatterAttributes = {
|
||||
layout?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SvelteMarkdownPreprocessLayouts = {
|
||||
_?: string;
|
||||
default?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
|
||||
const SCRIPT_BODY_REGEX = /<script.*>(?<body>(.|\n)*?)<\/script>/;
|
||||
|
||||
export type SvelteMarkdownPreprocessOptions = {
|
||||
/** defaults to `['.md', '.mdx']` */
|
||||
extensions?: string[];
|
||||
layouts?: SvelteMarkdownPreprocessLayouts;
|
||||
};
|
||||
|
||||
export class SvelteMarkdownPreprocess {
|
||||
name = '@immich/svelte-markdown-preprocess';
|
||||
|
||||
#extensions: string[];
|
||||
#layouts: SvelteMarkdownPreprocessLayouts;
|
||||
#md: Marked;
|
||||
|
||||
constructor(options?: SvelteMarkdownPreprocessOptions) {
|
||||
this.#extensions = options?.extensions ?? ['.md', '.mdx'];
|
||||
this.#layouts = options?.layouts ?? {};
|
||||
this.#md = this.configure(new Marked());
|
||||
}
|
||||
|
||||
configure(md: Marked) {
|
||||
return md.use(markedSvelte());
|
||||
}
|
||||
|
||||
async markup({ filename, content }: { filename?: string; content: string }) {
|
||||
if (!filename) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.matches({ filename, content })) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Promise.resolve({ filename, content })
|
||||
.then((file) => this.parseFrontMatter(file))
|
||||
.then((file) => this.parseScriptBody(file))
|
||||
.then((file) => this.parseMarkdown(file))
|
||||
.then((file) => this.parseLayout(file))
|
||||
.then((file) => this.parseSvelte(file))
|
||||
.then((file) => ({ code: file.svelte }));
|
||||
}
|
||||
|
||||
matches(file: FileWithContent): boolean {
|
||||
return this.#extensions.some((extension) => file.filename.endsWith(extension));
|
||||
}
|
||||
|
||||
parseFrontMatter({ filename, content }: FileWithContent): MaybePromise<FileWithFrontMatter> {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const { attributes, body } = fm(content) as { attributes: FrontMatterAttributes; body: string };
|
||||
return { filename, body, attributes };
|
||||
}
|
||||
|
||||
parseScriptBody(file: FileWithFrontMatter): MaybePromise<FileWithScriptBody> {
|
||||
let scriptBody = '';
|
||||
let body = file.body;
|
||||
const match = SCRIPT_BODY_REGEX.exec(body);
|
||||
const bodyGroup = match?.groups?.body;
|
||||
if (bodyGroup) {
|
||||
scriptBody = bodyGroup.trim();
|
||||
body = body.slice(Math.max(0, match.index + match[0].length)).trim();
|
||||
}
|
||||
|
||||
return { ...file, body, scriptBody };
|
||||
}
|
||||
|
||||
async parseMarkdown(file: FileWithScriptBody): Promise<FileWithMarkup> {
|
||||
const markup = await this.#md.parse(file.body);
|
||||
return { ...file, markup };
|
||||
}
|
||||
|
||||
parseLayout(file: FileWithMarkup): MaybePromise<FileWithLayout> {
|
||||
const layoutKey = file.attributes.layout;
|
||||
const layout = layoutKey ? this.#layouts[layoutKey] : (this.#layouts.default ?? this.#layouts._);
|
||||
return { ...file, layout };
|
||||
}
|
||||
|
||||
parseSvelte(file: FileWithLayout): MaybePromise<FileWithSvelte> {
|
||||
const script = this.createSvelteScript(file);
|
||||
const template = this.createSvelteTemplate(file);
|
||||
const svelte = `${script}\n${template}`;
|
||||
return { ...file, svelte };
|
||||
}
|
||||
|
||||
scriptImports(file: FileWithLayout): Array<string | undefined> {
|
||||
return [
|
||||
` import { Markdown } from '@immich/ui';`,
|
||||
file.layout ? ` import Layout from '${file.layout}';` : undefined,
|
||||
];
|
||||
}
|
||||
|
||||
scriptExtras(file: FileWithLayout): Array<string | undefined> {
|
||||
return file.layout ? [` const attributes = ${JSON.stringify(file.attributes)};`] : [];
|
||||
}
|
||||
|
||||
createSvelteScript(file: FileWithLayout): string {
|
||||
return [
|
||||
`<script>`,
|
||||
...this.scriptImports(file).filter(Boolean),
|
||||
...this.scriptExtras(file).filter(Boolean),
|
||||
file.scriptBody,
|
||||
`</script>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
createSvelteTemplate(file: FileWithLayout): string {
|
||||
return (file.layout ? [`<Layout {attributes}>`, file.markup, '</Layout>'] : [file.markup]).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export const svelteMarkdownPreprocess = (options?: SvelteMarkdownPreprocessOptions) => {
|
||||
const plugin = new SvelteMarkdownPreprocess(options);
|
||||
return {
|
||||
name: plugin.name,
|
||||
markup: (item) => plugin.markup(item),
|
||||
} satisfies PreprocessorGroup;
|
||||
};
|
||||
export * from './markdown.js';
|
||||
export * from './utility.js';
|
||||
export * from './vite-preprocess.js';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { markedText } from './markdown.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe(markedText.name, () => {
|
||||
it('should return an empty string for empty input', () => {
|
||||
expect(markedText('')).toEqual('');
|
||||
});
|
||||
|
||||
it('should strip front matter', () => {
|
||||
expect(markedText('---\ntitle: Hello\nauthors: [Jane]\n---\n# Body here')).toEqual('Body here');
|
||||
});
|
||||
|
||||
it('should include heading text', () => {
|
||||
expect(markedText('# Title\n\nSome text.')).toEqual('Title Some text.');
|
||||
});
|
||||
|
||||
it('should join multiple paragraphs with a space', () => {
|
||||
expect(markedText('First paragraph.\n\nSecond paragraph.')).toEqual('First paragraph. Second paragraph.');
|
||||
});
|
||||
|
||||
it('should collapse whitespace and trim', () => {
|
||||
expect(markedText('One.\n\n\nTwo. Three.')).toEqual('One. Two. Three.');
|
||||
});
|
||||
|
||||
it('should render links as their visible text', () => {
|
||||
expect(markedText('[test](https://immich.app) is cool')).toEqual('test is cool');
|
||||
});
|
||||
|
||||
it('should render bold and italic as plain text', () => {
|
||||
expect(markedText('Hello **bold** and _em_')).toEqual('Hello bold and em');
|
||||
});
|
||||
|
||||
it('should render inline code as plain text', () => {
|
||||
expect(markedText('Run `npm install` now')).toEqual('Run npm install now');
|
||||
});
|
||||
|
||||
it('should render an image as its alt text', () => {
|
||||
expect(markedText('')).toEqual('alt text');
|
||||
});
|
||||
|
||||
it('should convert emoji shortcodes', () => {
|
||||
expect(markedText('Backup complete :tada:')).toEqual('Backup complete 🎉');
|
||||
});
|
||||
|
||||
describe('admonitions', () => {
|
||||
it('should keep the body and drop the ::: markers', () => {
|
||||
expect(markedText(':::info\nThis is **important** info.\n:::')).toEqual('This is important info.');
|
||||
});
|
||||
|
||||
it('should render an admonition with a title', () => {
|
||||
expect(markedText(':::tip Pro tip\nDo the thing.\n:::')).toEqual('Do the thing.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('html / jsx', () => {
|
||||
it('should keep the inner text of a block-level component', () => {
|
||||
expect(markedText('<Button href="/x">Download now</Button>')).toEqual('Download now');
|
||||
});
|
||||
|
||||
it('should keep the inner text of an inline component', () => {
|
||||
expect(markedText('Click <Button>here</Button> today')).toEqual('Click here today');
|
||||
});
|
||||
|
||||
it('should remove script blocks entirely, including their contents', () => {
|
||||
expect(markedText('Before\n\n<script>\nconst x = 1;\n</script>\n\nAfter')).toEqual('Before After');
|
||||
});
|
||||
});
|
||||
});
|
||||
+60
-4
@@ -1,12 +1,13 @@
|
||||
/* eslint-disable unicorn/no-this-outside-of-class */
|
||||
import { MarkedExtension, Token, Tokens } from 'marked';
|
||||
import fmUntyped, { FrontMatterOptions, FrontMatterResult } from 'front-matter';
|
||||
import { Marked, MarkedExtension, Token, Tokens } from 'marked';
|
||||
import { emojify } from 'node-emoji';
|
||||
import { createAttributes, escapeHtml, escapeSvelteCode, getIdFromText } from './utility.js';
|
||||
|
||||
const fm = fmUntyped as unknown as <T>(file: string, options?: FrontMatterOptions) => FrontMatterResult<T>;
|
||||
|
||||
const ALERT_VARIANTS = ['note', 'tip', 'important', 'warning', 'caution', 'info', 'success', 'danger'] as const;
|
||||
|
||||
const GFM_ALERT_REGEX = new RegExp(String.raw`^\[!(?<variant>${ALERT_VARIANTS.join('|')})\]\s*?\n*`, 'i');
|
||||
|
||||
const ADMONITION_REGEX = new RegExp(
|
||||
String.raw`^:::(?<variant>${ALERT_VARIANTS.join('|')})(?:[ \t]+(?<title>[^\n]+?))?[ \t]*\n(?<body>[\s\S]*?)\n:::[ \t]*(?:\n|$)`,
|
||||
);
|
||||
@@ -34,6 +35,8 @@ export const markedSvelte = (): MarkedExtension => ({
|
||||
if (token.type !== 'blockquote') {
|
||||
return;
|
||||
}
|
||||
|
||||
// parse github admonitions as alerts
|
||||
const blockquote = token as Tokens.Blockquote;
|
||||
const match = GFM_ALERT_REGEX.exec(blockquote.text);
|
||||
if (!match?.groups) {
|
||||
@@ -202,7 +205,8 @@ export const markedSvelte = (): MarkedExtension => ({
|
||||
return `<Markdown.LineBreak />\n`;
|
||||
},
|
||||
|
||||
link({ href, title, text }) {
|
||||
link({ href, title, tokens }) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return `<Markdown.Link${createAttributes({ href, title })}>${text}</Markdown.Link>`;
|
||||
},
|
||||
|
||||
@@ -211,3 +215,55 @@ export const markedSvelte = (): MarkedExtension => ({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Render markdown to plain text */
|
||||
export const markedText = (markdown: string): string => {
|
||||
const md = new Marked().use(markedSvelte());
|
||||
const { body } = fm<{ body: string }>(markdown);
|
||||
|
||||
return (
|
||||
md
|
||||
.lexer(body)
|
||||
.map((token) => blockToText(token))
|
||||
.join(' ')
|
||||
// strip html/jsx tags, keeping their inner text
|
||||
.replaceAll(/<[^>]*>/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim()
|
||||
);
|
||||
};
|
||||
|
||||
const blockToText = (token: Token): string => {
|
||||
switch (token.type) {
|
||||
case 'html': {
|
||||
// remove script blocks
|
||||
return /^\s*<script[\s>]/i.test(token.text) ? '' : token.text;
|
||||
}
|
||||
|
||||
case 'alert': {
|
||||
return ((token as Tokens.Generic).tokens ?? []).map((child) => blockToText(child)).join(' ');
|
||||
}
|
||||
case 'heading':
|
||||
case 'paragraph': {
|
||||
return inlineToText(token.tokens ?? []);
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inlineToText = (tokens: Token[]): string =>
|
||||
tokens
|
||||
.map((token) => {
|
||||
if ('tokens' in token && token.tokens) {
|
||||
return inlineToText(token.tokens);
|
||||
}
|
||||
|
||||
if ('text' in token) {
|
||||
return emojify(token.text);
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
@@ -0,0 +1,145 @@
|
||||
import fm from 'front-matter';
|
||||
import { Marked } from 'marked';
|
||||
import type { PreprocessorGroup } from 'svelte/compiler';
|
||||
import { markedSvelte } from './markdown.js';
|
||||
|
||||
type MaybePromise<T> = Promise<T> | T;
|
||||
|
||||
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 FileWithSvelte = FileWithLayout & { svelte: string };
|
||||
|
||||
export type FrontMatterAttributes = {
|
||||
layout?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SvelteMarkdownPreprocessLayouts = {
|
||||
_?: string;
|
||||
default?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
|
||||
const SCRIPT_BODY_REGEX = /<script.*>(?<body>(.|\n)*?)<\/script>/;
|
||||
|
||||
export type SvelteMarkdownPreprocessOptions = {
|
||||
/** defaults to `['.md', '.mdx']` */
|
||||
extensions?: string[];
|
||||
layouts?: SvelteMarkdownPreprocessLayouts;
|
||||
};
|
||||
|
||||
export class SvelteMarkdownPreprocess {
|
||||
name = '@immich/svelte-markdown-preprocess';
|
||||
|
||||
#extensions: string[];
|
||||
#layouts: SvelteMarkdownPreprocessLayouts;
|
||||
#md: Marked;
|
||||
|
||||
constructor(options?: SvelteMarkdownPreprocessOptions) {
|
||||
this.#extensions = options?.extensions ?? ['.md', '.mdx'];
|
||||
this.#layouts = options?.layouts ?? {};
|
||||
this.#md = this.configure(new Marked());
|
||||
}
|
||||
|
||||
configure(md: Marked) {
|
||||
return md.use(markedSvelte());
|
||||
}
|
||||
|
||||
async markup({ filename, content }: { filename?: string; content: string }) {
|
||||
if (!filename) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.matches({ filename, content })) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Promise.resolve({ filename, content })
|
||||
.then((file) => this.parseFrontMatter(file))
|
||||
.then((file) => this.parseScriptBody(file))
|
||||
.then((file) => this.parseMarkdown(file))
|
||||
.then((file) => this.parseLayout(file))
|
||||
.then((file) => this.parseSvelte(file))
|
||||
.then((file) => ({ code: file.svelte }));
|
||||
}
|
||||
|
||||
matches(file: FileWithContent): boolean {
|
||||
return this.#extensions.some((extension) => file.filename.endsWith(extension));
|
||||
}
|
||||
|
||||
parseFrontMatter({ filename, content }: FileWithContent): MaybePromise<FileWithFrontMatter> {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const { attributes, body } = fm(content) as { attributes: FrontMatterAttributes; body: string };
|
||||
return { filename, body, attributes };
|
||||
}
|
||||
|
||||
parseScriptBody(file: FileWithFrontMatter): MaybePromise<FileWithScriptBody> {
|
||||
let scriptBody = '';
|
||||
let body = file.body;
|
||||
const match = SCRIPT_BODY_REGEX.exec(body);
|
||||
const bodyGroup = match?.groups?.body;
|
||||
if (bodyGroup) {
|
||||
scriptBody = bodyGroup.trim();
|
||||
body = body.slice(Math.max(0, match.index + match[0].length)).trim();
|
||||
}
|
||||
|
||||
return { ...file, body, scriptBody };
|
||||
}
|
||||
|
||||
async parseMarkdown(file: FileWithScriptBody): Promise<FileWithMarkup> {
|
||||
const markup = await this.#md.parse(file.body);
|
||||
return { ...file, markup };
|
||||
}
|
||||
|
||||
parseLayout(file: FileWithMarkup): MaybePromise<FileWithLayout> {
|
||||
const layoutKey = file.attributes.layout;
|
||||
const layout = layoutKey ? this.#layouts[layoutKey] : (this.#layouts.default ?? this.#layouts._);
|
||||
return { ...file, layout };
|
||||
}
|
||||
|
||||
parseSvelte(file: FileWithLayout): MaybePromise<FileWithSvelte> {
|
||||
const script = this.createSvelteScript(file);
|
||||
const template = this.createSvelteTemplate(file);
|
||||
const svelte = `${script}\n${template}`;
|
||||
return { ...file, svelte };
|
||||
}
|
||||
|
||||
scriptImports(file: FileWithLayout): Array<string | undefined> {
|
||||
return [
|
||||
` import { Markdown } from '@immich/ui';`,
|
||||
file.layout ? ` import Layout from '${file.layout}';` : undefined,
|
||||
];
|
||||
}
|
||||
|
||||
scriptExtras(file: FileWithLayout): Array<string | undefined> {
|
||||
return file.layout ? [` const attributes = ${JSON.stringify(file.attributes)};`] : [];
|
||||
}
|
||||
|
||||
createSvelteScript(file: FileWithLayout): string {
|
||||
return [
|
||||
`<script>`,
|
||||
...this.scriptImports(file).filter(Boolean),
|
||||
...this.scriptExtras(file).filter(Boolean),
|
||||
file.scriptBody,
|
||||
`</script>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
createSvelteTemplate(file: FileWithLayout): string {
|
||||
return (file.layout ? [`<Layout {attributes}>`, file.markup, '</Layout>'] : [file.markup]).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export const svelteMarkdownPreprocess = (options?: SvelteMarkdownPreprocessOptions) => {
|
||||
const plugin = new SvelteMarkdownPreprocess(options);
|
||||
return {
|
||||
name: plugin.name,
|
||||
markup: (item) => plugin.markup(item),
|
||||
} satisfies PreprocessorGroup;
|
||||
};
|
||||
Reference in New Issue
Block a user