feat: virtual docs module (#784)

* feat: virtual docs module

* chore: add readme
This commit is contained in:
Jason Rasmussen
2026-09-04 18:04:24 -04:00
committed by GitHub
parent d084b0832f
commit e748709956
25 changed files with 739 additions and 215 deletions
+1
View File
@@ -1,3 +1,4 @@
/// <reference types="@immich/svelte-markdown-preprocess/virtual" />
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
@@ -1,76 +1,100 @@
<script lang="ts">
import { blogMetadata, posts } from '$lib';
import { asBlogPost, blogMetadata, BlogType } from '$lib';
import BlogTypeBadge from '$lib/components/BlogTypeBadge.svelte';
import { Heading, Icon, Link, Markdown, SiteMetadata, Text } from '@immich/ui';
import {
Heading,
Icon,
Link,
Markdown,
SiteMetadata,
TableOfContents,
Text,
type TableOfContentsItem,
} from '@immich/ui';
import type { SerializedPost } from '$lib/types';
import { mdiChevronRight } from '@mdi/js';
import { DateTime } from 'luxon';
import type { Snippet } from 'svelte';
type Props = {
attributes: { id: string };
doc: SerializedPost;
children?: Snippet;
postScript?: Snippet;
};
let { attributes, children, postScript }: Props = $props();
const post = $derived(posts.find((blog) => blog.id === attributes.id)!);
let { doc, children, postScript }: Props = $props();
const post = $derived(asBlogPost(doc));
let { title, publishedAt, authors, description } = $derived(post);
const alt = $derived(post.coverAlt ?? 'Blog cover image');
const headers: TableOfContentsItem[] = $derived([
...post.headers,
...(postScript ? [{ id: 'faqs', text: 'FAQs', level: 2 }] : []),
]);
</script>
<SiteMetadata site={blogMetadata} page={{ title, description }} />
<div>
<ul class="flex place-items-center gap-1 text-muted">
<li class="flex place-items-center">
<Link href="/blog" underline={false}><span class="hover:underline">Blog</span></Link>
<Icon icon={mdiChevronRight} size="1rem" />
</li>
<li>{title}</li>
</ul>
<div class="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr]">
<article
class={['mx-auto w-full min-w-0 xl:col-start-2', post.type === BlogType.Release ? 'max-w-3xl' : 'max-w-2xl']}
>
<div>
<ul class="flex place-items-center gap-1 text-muted">
<li class="flex place-items-center">
<Link href="/blog" underline={false}><span class="hover:underline">Blog</span></Link>
<Icon icon={mdiChevronRight} size="1rem" />
</li>
<li>{title}</li>
</ul>
<Heading tag="h1" size="giant" class="mt-6">
{post.title}
</Heading>
<Heading tag="h1" size="giant" class="mt-6">
{post.title}
</Heading>
<div class="mt-4 mb-2 flex gap-1">
<Text color="muted" size="small" variant="italic">{publishedAt.toLocaleString(DateTime.DATE_FULL)}</Text>
<Text color="muted" size="small">— {authors.join(', ')}</Text>
</div>
<div class="mt-4 mb-2 flex gap-1">
<Text color="muted" size="small" variant="italic">{publishedAt.toLocaleString(DateTime.DATE_FULL)}</Text>
<Text color="muted" size="small">— {authors.join(', ')}</Text>
</div>
<Markdown.Paragraph><em>{description}</em></Markdown.Paragraph>
<Markdown.Paragraph><em>{description}</em></Markdown.Paragraph>
<BlogTypeBadge class="mt-2" size="small" type={post.type} />
<BlogTypeBadge class="mt-2" size="small" type={post.type} />
{#if post.coverUrl}
<Markdown.Image
src={post.coverUrl}
srcset={post.coverSrcset}
width={post.coverWidth}
height={post.coverHeight}
{alt}
priority
>
{#snippet caption()}
{#if post.coverAttribution}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html post.coverAttribution} - {alt}
{:else}
{#if post.coverUrl}
<Markdown.Image
src={post.coverUrl}
srcset={post.coverSrcset}
width={post.coverWidth}
height={post.coverHeight}
{alt}
{/if}
{/snippet}
</Markdown.Image>
priority
>
{#snippet caption()}
{#if post.coverAttribution}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html post.coverAttribution} - {alt}
{:else}
{alt}
{/if}
{/snippet}
</Markdown.Image>
{/if}
<Markdown.LineBreak />
</div>
{@render children?.()}
<Text class="mt-4">Cheers,<br />The Immich Team</Text>
{#if postScript}
<Markdown.LineBreak />
<Markdown.Heading level={2} id="faqs">FAQs</Markdown.Heading>
{@render postScript?.()}
{/if}
</article>
{#if headers.length > 1}
<TableOfContents items={headers} class="ms-8 xl:col-start-3" />
{/if}
<Markdown.LineBreak />
</div>
{@render children?.()}
<Text class="mt-4">Cheers,<br />The Immich Team</Text>
{#if postScript}
<Markdown.LineBreak />
<Markdown.Heading level={2} id="faqs">FAQs</Markdown.Heading>
{@render postScript?.()}
{/if}
@@ -6,20 +6,16 @@
import type { Snippet } from 'svelte';
type Props = {
attributes: {
title: string;
description: string;
updatedAt: string;
};
doc: { attributes: { title: string; description: string; updatedAt: string } };
children?: Snippet;
};
let { attributes, children }: Props = $props();
let { doc, children }: Props = $props();
const updatedAt = $derived(DateTime.fromISO(attributes.updatedAt).setZone('UTC'));
const updatedAt = $derived(DateTime.fromISO(doc.attributes.updatedAt).setZone('UTC'));
const pageMetadata = $derived({
title: attributes.title,
description: attributes.description,
title: doc.attributes.title,
description: doc.attributes.description,
});
</script>
+1 -1
View File
@@ -36,7 +36,7 @@ describe('posts', () => {
expect(post.description, post.url).toEqual(expect.any(String));
expect(post.authors.length, post.url).toBeGreaterThan(0);
expect(post.publishedAt.isValid, post.url).toBe(true);
expect(post.markdown, post.url).toContain('---');
expect(post.headers, post.url).toEqual(expect.any(Array));
}
});
+23 -121
View File
@@ -1,5 +1,7 @@
import fm from 'front-matter';
import { BlogType, type BlogPost, type SerializedPost } from '$lib/types';
import type { ClientDoc } from '@immich/svelte-markdown-preprocess';
import { DateTime } from 'luxon';
import { getDocs } from 'virtual:docs';
export const siteMetadata = {
title: 'Immich',
@@ -25,30 +27,8 @@ export type TimelineItem = {
export const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
type Attributes = {
/**
uuid-v7, which can be generated with `npx -y uuid v7`
*/
id: string;
title: string;
description: string;
featured?: boolean;
authors: string[];
coverUrl?: string;
coverSrcset?: string;
coverWidth?: number;
coverHeight?: number;
coverAlt?: string;
coverAttribution?: string;
};
// keep in sync with blog/(type) folders
export enum BlogType {
Announcement = 'announcement',
Post = 'post',
Recap = 'recap',
Release = 'release',
}
export { BlogType } from '$lib/types';
export type { BlogPost, SerializedPost } from '$lib/types';
export const isBlogType = (value: string | BlogType): value is BlogType => {
return Object.values(BlogType).includes(value as BlogType);
@@ -56,107 +36,29 @@ export const isBlogType = (value: string | BlogType): value is BlogType => {
export const typeToLabel = (type: BlogType) => capitalize(type);
export type BlogPost = Attributes & {
publishedAt: DateTime;
modifiedAt?: DateTime;
url: string;
type: BlogType;
markdown: string;
};
const asDateTime = (value: string) => DateTime.fromISO(value, { zone: 'UTC' }) as DateTime<true>;
type PostFrontMatter = Attributes & {
publishedAt: Date;
modifiedAt?: Date;
};
const getFrontMatterExample = (missingAttributes: string[]) => {
return [
'---',
...Object.entries({
id: 'your-uuid-v7-here',
title: 'Your post title',
description: 'A brief description of your post',
publishedAt: '2025-10-01',
authors: '[Author 1, Author 2]',
})
.filter(([key]) => missingAttributes.includes(key))
.map(([key, value]) => `${key}: ${value}`),
'---',
].join('\n');
};
const POST_PATH = /\/blog\/\((?<group>[^)]+)\)\/(?<slug>[^/]+)\/\+page\.md$/;
const asPost = (path: string, content: string): BlogPost => {
const attributes = fm<PostFrontMatter>(content).attributes;
const match = POST_PATH.exec(path);
if (!match?.groups) {
throw new Error(`${path} is not a valid blog post path - expected blog/(types)/slug/+page.md`);
const asTitle = ({ title, publishedAt }: BlogPost) => {
if (publishedAt < DateTime.now().minus({ years: 1 }) && title.endsWith(' recap')) {
return title.replaceAll(' recap', () => ` ${publishedAt.year} recap`);
}
const { slug, group } = match.groups;
const type = group.replace(/s$/, '');
return title;
};
const requiredAttributes = ['id', 'title', 'description', 'publishedAt', 'authors'];
const missingAttributes = requiredAttributes.filter((attribute) => !Object.hasOwn(attributes, attribute));
if (missingAttributes.length > 0) {
throw new Error(`${slug} is missing ${missingAttributes.join(', ')}.\n${getFrontMatterExample(missingAttributes)}`);
}
if (!isBlogType(type)) {
throw new Error(
`${slug} has incorrect blog type - found ${type}, but expected one of ${Object.values(BlogType).join(', ')}`,
);
}
return {
id: attributes.id,
type,
title: attributes.title,
description: attributes.description,
publishedAt: DateTime.fromJSDate(attributes.publishedAt, { zone: 'UTC' }) as DateTime<true>,
modifiedAt: attributes.modifiedAt
? (DateTime.fromJSDate(attributes.modifiedAt, { zone: 'UTC' }) as DateTime<true>)
: undefined,
authors: attributes.authors,
url: `/blog/${slug}`,
featured: attributes.featured,
coverUrl: attributes.coverUrl,
coverSrcset: attributes.coverSrcset,
coverWidth: attributes.coverWidth,
coverHeight: attributes.coverHeight,
coverAlt: attributes.coverAlt,
coverAttribution: attributes.coverAttribution,
markdown: content,
export const asBlogPost = (post: SerializedPost): BlogPost => {
const blogPost = {
...post,
publishedAt: asDateTime(post.publishedAt),
modifiedAt: post.modifiedAt ? asDateTime(post.modifiedAt) : undefined,
};
return { ...blogPost, title: asTitle(blogPost) };
};
const getPosts = () => {
const idMap = new Map<string, string>();
const modules = import.meta.glob<{ default: string }>('../routes/**/blog/**/*.md', {
query: '?raw',
eager: true,
});
const posts: BlogPost[] = [];
for (const [path, { default: content }] of Object.entries(modules)) {
const post = asPost(path, content);
const isPost = (doc: ClientDoc): doc is SerializedPost => 'type' in doc;
if (idMap.has(post.id)) {
throw new Error(
`Detected a duplicate blog ID! ${post.id} is used in ${path} and ${idMap.get(post.id)}. Hint: use pnpm uuid to generate a new uuid-v7`,
);
}
idMap.set(post.id, path);
if (post.publishedAt < DateTime.now().minus({ years: 1 }) && post.title.endsWith(' recap')) {
post.title = post.title.replaceAll(' recap', () => ` ${post.publishedAt.year} recap`);
}
posts.push(post);
}
return posts.toSorted((a, b) => b.publishedAt.valueOf() - a.publishedAt.valueOf());
};
export const posts: BlogPost[] = getPosts();
export const posts: BlogPost[] = getDocs<SerializedPost | ClientDoc>()
.filter((doc) => isPost(doc))
.map((doc) => asBlogPost(doc))
.toSorted((a, b) => b.publishedAt.valueOf() - a.publishedAt.valueOf());
@@ -0,0 +1,22 @@
import { getHrefFromPath, markedText } from '@immich/svelte-markdown-preprocess';
const ROUTES = '../../routes/';
const files = import.meta.glob('../../routes/**/blog/**/+page.md', {
query: '?raw',
eager: true,
import: 'default',
});
const textByUrl = new Map(
Object.entries(files).map(([path, content]) => [getHrefFromPath(path.slice(ROUTES.length)), content]),
);
export const getPostText = (url: string) => {
const markdown = textByUrl.get(url);
if (markdown === undefined) {
throw new Error(`Could not find the markdown for ${url}`);
}
return markedText(markdown);
};
+39
View File
@@ -1,4 +1,6 @@
import type { ClientDoc } from '@immich/svelte-markdown-preprocess';
import type { IconLike } from '@immich/ui';
import type { DateTime } from 'luxon';
export type Feature = {
title: string;
@@ -11,3 +13,40 @@ export type FeatureLink = {
href: string;
text: string;
};
// keep in sync with blog/(type) folders
export enum BlogType {
Announcement = 'announcement',
Post = 'post',
Recap = 'recap',
Release = 'release',
}
type Attributes = ClientDoc & {
/**
uuid-v7, which can be generated with `npx -y uuid v7`
*/
id: string;
title: string;
description: string;
featured?: boolean;
authors: string[];
coverUrl?: string;
coverSrcset?: string;
coverWidth?: number;
coverHeight?: number;
coverAlt?: string;
coverAttribution?: string;
url: string;
type: BlogType;
};
export type SerializedPost = Attributes & {
publishedAt: string;
modifiedAt?: string;
};
export type BlogPost = Attributes & {
publishedAt: DateTime;
modifiedAt?: DateTime;
};
@@ -10,21 +10,21 @@
let { children }: Props = $props();
const isReleaseNotePage = $derived(!!page.route.id?.includes('releases'));
const isIndexPage = $derived(page.route.id === '/(shell)/blog');
const styles = tv({
base: 'mx-auto',
base: 'mx-auto w-full',
variants: {
releaseNotes: {
true: 'max-w-3xl',
false: 'max-w-2xl',
index: {
true: 'max-w-2xl',
false: '',
},
},
});
</script>
<PageContent>
<div class={styles({ releaseNotes: isReleaseNotePage })}>
<div class={styles({ index: isIndexPage })}>
{@render children?.()}
</div>
</PageContent>
@@ -1,6 +1,6 @@
import { posts, typeToLabel, type BlogPost } from '$lib';
import type { SearchDoc } from '$lib/search';
import { markedText } from '@immich/svelte-markdown-preprocess';
import { getPostText } from '$lib/server/posts';
import { json } from '@sveltejs/kit';
export const prerender = true;
@@ -11,7 +11,7 @@ const fromBlogPost = (post: BlogPost): SearchDoc => {
description: post.description,
url: post.url,
tags: [typeToLabel(post.type)],
text: markedText(post.markdown),
text: getPostText(post.url),
};
};
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
+63 -1
View File
@@ -1,9 +1,71 @@
import {
getHrefFromPath,
svelteMarkdownVite,
type ClientDoc,
type ServerDoc,
} from '@immich/svelte-markdown-preprocess';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { z } from 'zod';
import { BlogType, type SerializedPost } from './src/lib/types.ts';
const PostSchema = z.object({
id: z.uuid(),
type: z.enum(BlogType),
title: z.string().nonempty(),
description: z.string().nonempty(),
featured: z.boolean().optional(),
authors: z.array(z.string().nonempty()).nonempty(),
coverUrl: z.url().optional(),
coverSrcset: z.string().optional(),
coverWidth: z.number().optional(),
coverHeight: z.number().optional(),
coverAlt: z.string().optional(),
coverAttribution: z.string().optional(),
publishedAt: z.date(),
modifiedAt: z.date().optional(),
});
const POST_PATH = /(?:^|\/)blog\/\((?<group>[^)]+)\)\/[^/]+\/\+page\.[^.]+$/;
const seen = new Map<string, string>();
export const onDoc = ({ path, attributes, headers }: ServerDoc): SerializedPost | ClientDoc => {
const group = POST_PATH.exec(path)?.groups?.group;
if (!group) {
return { path, attributes, headers };
}
const parsed = PostSchema.safeParse({ ...attributes, type: group.replace(/s$/, '') });
if (!parsed.success) {
throw new Error(`${path} has invalid front matter:\n${z.prettifyError(parsed.error)}`);
}
const post = parsed.data;
const duplicate = seen.get(post.id);
if (duplicate && duplicate !== path) {
throw new Error(
`Detected a duplicate blog ID! ${post.id} is used in ${path} and ${duplicate}. Hint: use pnpm uuid to generate a new uuid-v7`,
);
}
seen.set(post.id, path);
return {
path,
attributes,
headers,
url: getHrefFromPath(path),
...post,
publishedAt: post.publishedAt.toISOString(),
modifiedAt: post.modifiedAt?.toISOString(),
};
};
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
plugins: [tailwindcss(), sveltekit(), svelteMarkdownVite({ onDoc })],
server: {
fs: {
allow: ['../../common'],
+1
View File
@@ -1,3 +1,4 @@
/// <reference types="@immich/svelte-markdown-preprocess/virtual" />
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
@@ -14,4 +14,4 @@
const { size, name, localeSensitive = false, description, children }: Props = $props();
</script>
<MarkdownPage attributes={{ title: name, description }} {localeSensitive} {size} {children} />
<MarkdownPage doc={{ attributes: { title: name, description } }} {localeSensitive} {size} {children} />
@@ -5,14 +5,14 @@
import type { Snippet } from 'svelte';
type Props = {
attributes: { title: string; description: string };
doc?: { attributes: { title?: string; description?: string } };
localeSensitive?: boolean;
size?: ContainerSize;
children?: Snippet;
};
const { attributes, localeSensitive = false, size = 'medium', children }: Props = $props();
const { title, description } = $derived(attributes);
const { doc, localeSensitive = false, size = 'medium', children }: Props = $props();
const { title = '', description = '' } = $derived(doc?.attributes ?? {});
const page = $derived({ title, description });
@@ -5,7 +5,10 @@
import { components } from '$lib/constants.js';
</script>
<MarkdownPage attributes={{ title: 'Components', description: 'A list of all the Immich UI components' }} size="large">
<MarkdownPage
doc={{ attributes: { title: 'Components', description: 'A list of all the Immich UI components' } }}
size="large"
>
<Grid>
{#each components as component (component.name)}
<ComponentCard {component} />
+2 -1
View File
@@ -1,9 +1,10 @@
import { svelteMarkdownVite } from '@immich/svelte-markdown-preprocess';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, type UserConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
plugins: [tailwindcss(), sveltekit(), svelteMarkdownVite()],
server: {
fs: {
allow: ['../../common'],