feat: search (#618)

feat: june recap (#615)
This commit is contained in:
Jason Rasmussen
2026-07-02 13:28:13 -04:00
committed by GitHub
parent f596f445c2
commit fdf6895012
21 changed files with 510 additions and 215 deletions
@@ -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('![alt text](/img.png)')).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');
});
});
});
@@ -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;
};