feat: optimized srcset images (#742)

* optimized images

enable threading

update readme

67

try increasing timeout

de-essay-ify

depend on ui

simplify

update readme

handle srcset in markup, simplify

shorter naming

* command to delete stale objects

* fix path

* rename
This commit is contained in:
Mert
2026-09-03 14:35:24 -04:00
committed by GitHub
parent 6abfb6bd11
commit 7cae95653c
26 changed files with 568 additions and 109 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Cleanup bucket
on:
workflow_dispatch:
schedule:
- cron: '0 3 1 * *'
env:
MISE_TRUSTED_CONFIG_PATHS: ${{ github.workspace }}/.mise/config.toml
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: 'main'
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
- name: Get pnpm store directory
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install
run: pnpm install --frozen-lockfile
- name: Cleanup bucket
env:
R2_BUCKET_NAME: ${{ secrets.STATIC_BUCKET_NAME }}
R2_ENDPOINT_URL: ${{ secrets.STATIC_BUCKET_ENDPOINT }}
R2_ACCESS_KEY_ID: ${{ secrets.STATIC_BUCKET_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.STATIC_BUCKET_KEY_SECRET }}
R2_PUBLIC_URL: 'https://static.immich.cloud'
run: mise cleanup-bucket
+5
View File
@@ -70,6 +70,11 @@ dir = "{{config_root}}"
run = "pnpm --filter @immich/import-post run import"
env = { _.file = ".env" }
[tasks.cleanup-bucket]
dir = "{{config_root}}"
run = "pnpm --filter @immich/import-post run cleanup"
env = { _.file = ".env" }
[tasks.open]
usage = '''
@@ -27,7 +27,7 @@ describe('JWT Authentication', () => {
expect(response.status).toBe(200);
expect(data.token).toBeDefined();
expect(data.token).toMatch(/^[A-Za-z0-9-_.]+$/);
});
}, 10_000);
});
describe('EXIF Dataset upload API worker', () => {
@@ -43,7 +43,14 @@
<BlogTypeBadge class="mt-2" size="small" type={post.type} />
{#if post.coverUrl}
<Markdown.Image src={post.coverUrl} {alt}>
<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 -->
@@ -1,7 +1,7 @@
<script lang="ts">
import { type BlogPost, type BlogType } from '$lib';
import BlogTypeBadge from '$lib/components/BlogTypeBadge.svelte';
import { Button, Heading, Markdown, Text } from '@immich/ui';
import { Button, Heading, IMAGE_SIZES_QUERY, Markdown, Text } from '@immich/ui';
import { mdiChevronRight } from '@mdi/js';
import { DateTime } from 'luxon';
@@ -25,7 +25,16 @@
</div>
{#if post.coverUrl}
<img src={post.coverUrl} alt={post.coverAlt} class="overflow-hidden rounded-lg" />
<img
src={post.coverUrl}
srcset={post.coverSrcset}
sizes={post.coverSrcset && IMAGE_SIZES_QUERY}
alt={post.coverAlt}
width={post.coverWidth}
height={post.coverHeight}
loading="lazy"
class="overflow-hidden rounded-lg"
/>
{/if}
<div class="mt-4">
+6
View File
@@ -35,6 +35,9 @@ type Attributes = {
featured?: boolean;
authors: string[];
coverUrl?: string;
coverSrcset?: string;
coverWidth?: number;
coverHeight?: number;
coverAlt?: string;
coverAttribution?: string;
};
@@ -119,6 +122,9 @@ const asPost = (path: string, content: string): BlogPost => {
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,
+17 -3
View File
@@ -4,15 +4,18 @@ Imports an [Outline](https://www.getoutline.com/) document into the blog. It:
1. Fetches the document from the Outline API.
2. Downloads every referenced image/video attachment.
3. Optimizes and uploads them to and R2 bucket using md5 hash as filename
4. Updates the markdown link to use static.immich.cloud
3. Optimizes each image into a responsive ladder, capped at the source width, and uploads every
rung to R2 as `<hash>-<rung>.<ext>`. The hash covers the source bytes and the pipeline version,
so an encoder change invalidates what is already in the bucket.
4. Replaces the markdown image with `<Markdown.Image src srcset width height alt />`, so a post
keeps the variants it was built with and a later ladder change cannot orphan it.
5. Writes the updated markdown to disk
6. Runs `prettier --write` on the file
## Usage
```bash
pnpm --filter @immich/import-post run import "<outline-post-url>"
mise import-post "<outline-post-url>"
```
Required environment variables:
@@ -27,3 +30,14 @@ Required environment variables:
| `R2_PUBLIC_URL` | Public base URL for uploaded assets |
The post URL may also be supplied via `OUTLINE_POST_URL` instead of an argument.
## Cleanup
Imports never delete, so re-imports and pipeline changes leave old objects behind. The
`Cleanup bucket` workflow runs monthly from `main` and deletes every object under `blog/` that no
post references. Objects uploaded within the last 30 days are kept, since an open import PR
references its uploads only on its branch. To run it by hand:
```bash
mise cleanup-bucket
```
+1
View File
@@ -15,6 +15,7 @@
},
"scripts": {
"import": "tsx src/index.ts",
"cleanup": "tsx src/cleanup.ts",
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
+23
View File
@@ -0,0 +1,23 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ConfigRepository } from './repositories/config.repository.js';
import { R2Repository } from './repositories/r2.repository.js';
import { SystemRepository } from './repositories/system.repository.js';
import { staleKeys } from './stale-keys.js';
const { rootPath, r2 } = ConfigRepository.create().get();
const r2Repository = new R2Repository(r2);
const blog = join(rootPath, 'apps/root.immich.app/src/routes/blog');
const posts = readdirSync(blog, { recursive: true, encoding: 'utf8' })
.filter((path) => path.endsWith('+page.md'))
.map((path) => readFileSync(join(blog, path), 'utf8'));
const objects = await r2Repository.listObjects('blog/');
const stale = staleKeys(posts.join('\n'), r2.publicUrl, objects);
console.log([`Bucket: ${objects.length} objects, ${stale.length} stale`, ...stale].join('\n'));
if (stale.length > 0 && (await new SystemRepository().confirm())) {
await r2Repository.deleteKeys(stale);
console.log(`Deleted ${stale.length} objects`);
}
+6 -2
View File
@@ -1,7 +1,11 @@
export const WEBP_QUALITY = 85;
// Outline links to attachments (videos) via this redirect path.
export const ATTACHMENT_PREFIX = '/api/attachments.redirect';
// Matches a date-only string (e.g. `2026-03-02`) so it can be emitted unquoted.
export const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
// Mixed into the content hash, so a change here invalidates the bucket.
export const PIPELINE_VERSION = 'v5';
// An open import PR references its uploads only on its branch, so recent objects are never stale.
export const CLEANUP_GRACE_DAYS = 30;
@@ -22,7 +22,10 @@ export class ConfigRepository {
private constructor() {
this.config = {
outlineApiKey: required('OUTLINE_API_KEY'),
// Read lazily, so the cleanup workflow can omit the key
get outlineApiKey() {
return required('OUTLINE_API_KEY');
},
rootPath: fileURLToPath(new URL('../../../../', import.meta.url)),
r2: {
bucket: required('R2_BUCKET_NAME'),
@@ -0,0 +1,47 @@
import sharp from 'sharp';
import { describe, expect, it } from 'vitest';
import { MediaRepository } from './media.repository.js';
const sut = new MediaRepository();
const still = (width: number, height: number) =>
sharp({ create: { width, height, channels: 3, background: 'teal' } })
.png()
.toBuffer();
// Frames must differ, or the encoder collapses them into one page.
const animated = (frames: number, width: number, height: number) =>
sharp(Buffer.concat(Array.from({ length: frames }, (_, index) => Buffer.alloc(width * height * 3, index * 60))), {
raw: { width, height: height * frames, channels: 3, pageHeight: height },
})
.gif()
.toBuffer();
const sizes = (variants: { width: number; height: number }[]) =>
variants.map(({ width, height }) => `${width}x${height}`);
describe(MediaRepository.name, () => {
it.each([
[3210, 2140, ['720x480', '1080x720', '1440x960', '2160x1440']],
[1500, 1000, ['720x480', '1080x720', '1500x1000']],
[500, 700, ['500x700']],
])('builds an AVIF ladder for a %ix%i still', async (width, height, rungs) => {
const variants = await sut.optimizeImage(await still(width, height));
expect(sizes(variants)).toEqual(rungs);
expect(variants.every((variant) => variant.extension === 'avif')).toBe(true);
for (const variant of variants) {
const metadata = await sharp(variant.buffer).metadata();
expect(`${metadata.width}x${metadata.height}`).toBe(`${variant.width}x${variant.height}`);
}
});
it('builds a WebP ladder for an animation, keeping every frame', async () => {
const variants = await sut.optimizeImage(await animated(3, 900, 600));
const metadata = await sharp(variants[0].buffer, { animated: true }).metadata();
expect(sizes(variants)).toEqual(['720x480', '900x600']);
expect(variants.every((variant) => variant.extension === 'webp')).toBe(true);
expect({ pages: metadata.pages, pageHeight: metadata.pageHeight }).toEqual({ pages: 3, pageHeight: 480 });
});
});
@@ -1,15 +1,33 @@
import { availableParallelism } from 'node:os';
import type { Channels, OutputInfo } from 'sharp';
import sharp from 'sharp';
import { WEBP_QUALITY } from '../constants.js';
import { OptimizeResult } from '../types.js';
import type { ImageVariant, OptimizeResult } from '../types.js';
// Each post ships the srcset it was built with, so changing these only affects later imports.
const IMAGE_WIDTHS = [720, 1080, 1440, 2160];
// A rung within a hair of the top one can encode larger than the wider rung it duplicates.
const imageLadder = (sourceWidth: number) => {
const top = Math.min(sourceWidth, IMAGE_WIDTHS.at(-1)!);
return [...IMAGE_WIDTHS.filter((width) => width < top * 0.9), top];
};
type P3Bitmap = { pixels: Uint16Array; raw: { width: number; height: number; channels: Channels } };
// sharp takes buffer depth from the TypedArray constructor rather than `info.depth`
const toP3Bitmap = ({ data, info }: { data: Buffer; info: OutputInfo }): P3Bitmap => ({
pixels: new Uint16Array(data.buffer, data.byteOffset, data.byteLength / 2),
raw: { width: info.width, height: info.height, channels: info.channels as Channels },
});
export class MediaRepository {
async optimizeImage(buffer: Buffer): Promise<OptimizeResult> {
const output = await sharp(buffer, { animated: true }).webp({ quality: WEBP_QUALITY }).toBuffer();
return {
buffer: output,
extension: 'webp',
contentType: 'image/webp',
};
constructor() {
sharp.concurrency(availableParallelism()); // sharp pins libvips to one thread on glibc without jemalloc
}
async optimizeImage(buffer: Buffer): Promise<ImageVariant[]> {
const { pages, width } = await sharp(buffer).metadata();
return pages && pages > 1 ? this.encodeAnimations(buffer, width) : this.encodeStills(buffer);
}
async optimizeVideo(buffer: Buffer): Promise<OptimizeResult> {
@@ -21,4 +39,58 @@ export class MediaRepository {
contentType: 'video/mp4',
};
}
private async encodeStills(buffer: Buffer): Promise<ImageVariant[]> {
const source = await this.decode(buffer);
const variants: ImageVariant[] = [];
for (const width of imageLadder(source.raw.width)) {
const { data, info } = await this.encodeStill(await this.resizeInLinearLight(source, width));
variants.push({ buffer: data, width: info.width, height: info.height, extension: 'avif' });
}
return variants;
}
private async encodeAnimations(buffer: Buffer, sourceWidth: number): Promise<ImageVariant[]> {
const variants: ImageVariant[] = [];
for (const width of imageLadder(sourceWidth)) {
const { data, info } = await sharp(buffer, { animated: true })
.resize({ width })
.webp({ quality: 85, effort: 6 }) // libvips doesn't support animated AVIF yet
.toBuffer({ resolveWithObject: true });
variants.push({ buffer: data, width: info.width, height: info.pageHeight!, extension: 'webp' });
}
return variants;
}
private async decode(buffer: Buffer): Promise<P3Bitmap> {
return toP3Bitmap(
await sharp(buffer, { autoOrient: true })
.pipelineColourspace('rgb16')
.withIccProfile('p3')
.toColourspace('rgb16')
.raw({ depth: 'ushort' })
.toBuffer({ resolveWithObject: true }),
);
}
private async resizeInLinearLight(source: P3Bitmap, width: number): Promise<P3Bitmap> {
return toP3Bitmap(
await sharp(source.pixels, { raw: source.raw })
.pipelineColourspace('scrgb')
.resize({ width })
.toColourspace('rgb16')
.raw({ depth: 'ushort' })
.toBuffer({ resolveWithObject: true }),
);
}
// Folding this into the resize would encode against sRGB rather than P3
private encodeStill(bitmap: P3Bitmap) {
return sharp(bitmap.pixels, { raw: bitmap.raw })
.pipelineColourspace('rgb16')
.withIccProfile('p3')
.toColourspace('rgb16')
.avif({ quality: 67, bitdepth: 10, effort: 6 })
.toBuffer({ resolveWithObject: true });
}
}
@@ -1,5 +1,5 @@
import { DeleteObjectsCommand, paginateListObjectsV2, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import type { R2Config } from '../types.js';
import type { BucketObject, R2Config } from '../types.js';
export class R2Repository {
private readonly client: S3Client;
@@ -17,16 +17,16 @@ export class R2Repository {
});
}
async listKeys(prefix: string): Promise<string[]> {
const keys: string[] = [];
async listObjects(prefix: string): Promise<BucketObject[]> {
const objects: BucketObject[] = [];
for await (const page of paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix })) {
for (const object of page.Contents ?? []) {
if (object.Key) {
keys.push(object.Key);
for (const { Key, LastModified } of page.Contents ?? []) {
if (Key && LastModified) {
objects.push({ key: Key, lastModified: LastModified });
}
}
}
return keys;
return objects;
}
async upload(key: string, body: Buffer, contentType: string): Promise<void> {
@@ -36,15 +36,10 @@ export class R2Repository {
}
async deleteKeys(keys: string[]): Promise<void> {
if (keys.length === 0) {
return;
// DeleteObjects takes at most 1000 keys per request
for (let start = 0; start < keys.length; start += 1000) {
const Objects = keys.slice(start, start + 1000).map((Key) => ({ Key }));
await this.client.send(new DeleteObjectsCommand({ Bucket: this.bucket, Delete: { Objects } }));
}
await this.client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: { Objects: keys.map((Key) => ({ Key })) },
}),
);
}
}
@@ -41,7 +41,7 @@ export class SystemRepository {
}
}
async confirm(options?: { prompt?: string; timeoutSeconds: number }): Promise<boolean> {
async confirm(options?: { prompt?: string; timeoutSeconds?: number }): Promise<boolean> {
if (!process.stdin.isTTY) {
return true;
}
@@ -4,9 +4,27 @@ import type { MediaRepository } from '../repositories/media.repository.js';
import type { OutlineRepository } from '../repositories/outline.repository.js';
import type { R2Repository } from '../repositories/r2.repository.js';
import type { SystemRepository } from '../repositories/system.repository.js';
import { PIPELINE_VERSION } from '../constants.js';
import { ImportService } from './import.service.js';
const POST_URL = 'https://outline.immich/doc/abc';
const SOURCE_BUFFER = Buffer.from('image');
const RUNGS = [
[720, 480],
[1080, 720],
[1440, 960],
[2160, 1440],
] as const;
const IMAGE_VARIANTS = RUNGS.map(([width, height]) => ({
buffer: Buffer.from('avif'),
width,
height,
extension: 'avif',
}));
const variantKeys = (hash: string) => RUNGS.map(([width]) => `blog/post-id-1/${hash}-${width}.avif`);
type Mock<T extends object> = Mocked<Pick<T, keyof T>>;
type Real<T> = T extends Mock<infer U> ? U : never;
@@ -38,7 +56,7 @@ describe(ImportService.name, () => {
outlineMock = {
getDocument: vi.fn(),
download: vi.fn().mockResolvedValue({ buffer: Buffer.from('image'), contentType: 'image/png' }),
download: vi.fn().mockResolvedValue({ buffer: SOURCE_BUFFER, contentType: 'image/png' }),
getAttachmentId: vi.fn().mockReturnValue('attachment-id'),
};
@@ -51,16 +69,14 @@ describe(ImportService.name, () => {
};
mediaMock = {
optimizeImage: vi
.fn()
.mockResolvedValue({ buffer: Buffer.from('webp'), extension: 'webp', contentType: 'image/webp' }),
optimizeImage: vi.fn().mockResolvedValue(IMAGE_VARIANTS),
optimizeVideo: vi
.fn()
.mockResolvedValue({ buffer: Buffer.from('mp4'), extension: 'mp4', contentType: 'video/mp4' }),
};
r2Mock = {
listKeys: vi.fn().mockResolvedValue([]),
listObjects: vi.fn().mockResolvedValue([]),
upload: vi.fn(),
deleteKeys: vi.fn(),
};
@@ -90,8 +106,9 @@ slug: test
await sut.run(POST_URL);
expect(r2Mock.upload).toHaveBeenCalledTimes(1);
expect(r2Mock.upload).toHaveBeenCalledWith('blog/post-id-1/file-hash-1.webp', expect.any(Buffer), 'image/webp');
expect(r2Mock.upload.mock.calls).toEqual(
variantKeys('file-hash-1').map((key) => [key, expect.any(Buffer), 'image/avif']),
);
});
it('should not re-upload an image already present in the bucket', async () => {
@@ -106,7 +123,9 @@ slug: test
![alt](https://outline.immich/a.png)`,
});
systemMock.md5.mockReturnValue('file-hash-1');
r2Mock.listKeys.mockResolvedValue(['blog/post-id-1/file-hash-1.webp']);
r2Mock.listObjects.mockResolvedValue(
variantKeys('file-hash-1').map((key) => ({ key, lastModified: new Date() })),
);
await sut.run(POST_URL);
@@ -130,10 +149,10 @@ slug: test
await sut.run(POST_URL);
expect(r2Mock.upload).toHaveBeenCalledTimes(1);
expect(r2Mock.upload).toHaveBeenCalledTimes(RUNGS.length);
});
it('should delete orphaned attachments that are no longer referenced', async () => {
it('should hash the source rather than the encoder output', async () => {
outlineMock.getDocument.mockResolvedValue({
id: 'post-id-1',
title: 'Test',
@@ -145,11 +164,11 @@ slug: test
![alt](https://outline.immich/a.png)`,
});
systemMock.md5.mockReturnValue('file-hash-1');
r2Mock.listKeys.mockResolvedValue(['blog/post-id-1/file-hash-1.webp', 'blog/post-id-1/orphan.webp']);
await sut.run(POST_URL);
expect(r2Mock.deleteKeys).toHaveBeenCalledWith(['blog/post-id-1/orphan.webp']);
expect(systemMock.md5).toHaveBeenCalledTimes(1);
expect(systemMock.md5).toHaveBeenCalledWith(Buffer.concat([Buffer.from(PIPELINE_VERSION), SOURCE_BUFFER]));
});
});
@@ -319,11 +338,13 @@ type: post
await sut.run(POST_URL);
expect(systemMock.write).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('coverUrl: https://static.immich.cloud/blog/post-id-1/file-hash-1.webp'),
const frontMatter = systemMock.write.mock.calls[0][1];
expect(frontMatter).toContain(
'coverAlt: Cover alt\ncoverHeight: 1440\ncoverSrcset: https://static.immich.cloud/blog/post-id-1/file-hash-1-720.avif',
);
expect(frontMatter).toContain(
'coverUrl: https://static.immich.cloud/blog/post-id-1/file-hash-1-2160.avif\ncoverWidth: 2160',
);
expect(systemMock.write).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('coverAlt: Cover alt'));
});
it('should remove the cover image from the body', async () => {
@@ -386,6 +407,68 @@ type: release
});
});
describe('rendered markup', () => {
const withBody = (text: string) => {
outlineMock.getDocument.mockResolvedValue({
id: 'post-id-1',
title: 'Test',
text: `---\ntitle: Test\nslug: test\ntype: release\n---\n\n${text}`,
});
systemMock.md5.mockReturnValue('file-hash-1');
};
const body = () => systemMock.write.mock.calls.at(-1)![1];
it('should ship the whole srcset, so a later ladder change cannot orphan the post', async () => {
withBody('![A bird](https://outline.immich/a.png)');
await sut.run(POST_URL);
const stem = 'https://static.immich.cloud/blog/post-id-1/file-hash-1';
expect(body()).toContain(
`<Markdown.Image src="${stem}-2160.avif" srcset="${RUNGS.map(([width]) => `${stem}-${width}.avif ${width}w`).join(', ')}" width="2160" height="1440" alt="A bird" />`,
);
});
it('should leave the srcset off an image with a single rung', async () => {
withBody('![A bird](https://outline.immich/a.gif)');
mediaMock.optimizeImage.mockResolvedValue([
{ buffer: Buffer.from('webp'), width: 600, height: 400, extension: 'webp' },
]);
await sut.run(POST_URL);
expect(r2Mock.upload).toHaveBeenCalledWith(
'blog/post-id-1/file-hash-1-600.webp',
expect.any(Buffer),
'image/webp',
);
expect(body()).toContain(
'<Markdown.Image src="https://static.immich.cloud/blog/post-id-1/file-hash-1-600.webp" width="600" height="400" alt="A bird" />',
);
});
it('should escape braces, which would otherwise open a svelte expression', async () => {
withBody('![The {count} badge](https://outline.immich/a.png)');
await sut.run(POST_URL);
expect(body()).toContain('alt="The &lbrace;count&rbrace; badge"');
});
it('should keep the video markup', async () => {
withBody('[clip](/api/attachments.redirect?id=1)');
outlineMock.download.mockResolvedValue({ buffer: SOURCE_BUFFER, contentType: 'video/mp4' });
systemMock.md5.mockReturnValue('video-hash');
await sut.run(POST_URL);
expect(body()).toContain(
'<video autoplay src="https://static.immich.cloud/blog/post-id-1/video-hash.mp4" controls>Your browser does not support the video tag.</video>',
);
});
});
describe('markdown', () => {
it('should not escape angle-bracket placeholders with a backslash', async () => {
outlineMock.getDocument.mockResolvedValue({
@@ -1,4 +1,4 @@
import type { Html, Image, Link, Parent } from 'mdast';
import type { Image, Link, Parent } from 'mdast';
import { join } from 'node:path';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
@@ -6,7 +6,7 @@ import remarkStringify from 'remark-stringify';
import { unified } from 'unified';
import { visit } from 'unist-util-visit';
import { Document, isSeq, parse, Scalar, visit as visitYaml } from 'yaml';
import { ATTACHMENT_PREFIX, DATE_ONLY } from '../constants.js';
import { ATTACHMENT_PREFIX, DATE_ONLY, PIPELINE_VERSION } from '../constants.js';
import type { ConfigRepository } from '../repositories/config.repository.js';
import type { MediaRepository } from '../repositories/media.repository.js';
import type { OutlineRepository } from '../repositories/outline.repository.js';
@@ -14,14 +14,28 @@ import type { R2Repository } from '../repositories/r2.repository.js';
import type { SystemRepository } from '../repositories/system.repository.js';
import type { MarkdownDocument, OutlineAttachment, ParsedDocument } from '../types.js';
type Cover = {
url: string;
alt: string;
};
const cleanTitle = (title: string | null | undefined): string | undefined =>
title && !title.startsWith(' =') ? title : undefined;
// The markup lands in a Svelte template, where a bare brace would open an expression.
const ATTRIBUTE_ESCAPES: Record<string, string> = {
'&': '&amp;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
'{': '&lbrace;',
'}': '&rbrace;',
};
const attributes = (values: Record<string, string | number | undefined>): string =>
Object.entries(values)
.filter(([, value]) => value !== undefined)
.map(
([key, value]) =>
` ${key}="${String(value).replaceAll(/[&"<>{}]/g, (character) => ATTRIBUTE_ESCAPES[character])}"`,
)
.join('');
const slugify = (text: string): string =>
text
.toLowerCase()
@@ -99,13 +113,24 @@ export class ImportService {
return;
}
const existingKeys = new Set(await this.r2Repository.listKeys(bucketFolder));
const existing = await this.r2Repository.listObjects(bucketFolder);
const existingKeys = new Set(existing.map(({ key }) => key));
const referencedKeys = new Set<string>();
const upload = async (filename: string, body: Buffer, contentType: string): Promise<string> => {
const key = `${bucketFolder}/${filename}`;
if (!existingKeys.has(key) && !referencedKeys.has(key)) {
await this.r2Repository.upload(key, body, contentType);
console.log(`Uploaded: ${key}`);
}
referencedKeys.add(key);
return `${r2.publicUrl}/${key}`;
};
const document = markdown.parse(content);
const firstAsCover = postType !== 'release';
let cover: Cover | undefined;
let cover: Record<string, unknown> | undefined;
for (const attachment of this.getAttachments(document)) {
const url = new URL(attachment.url, postUrl).href;
const { buffer, contentType: originalContentType } = await this.outlineRepository.download(url);
@@ -118,38 +143,53 @@ export class ImportService {
console.log(`Processing attachment: ${attachmentId} (${originalContentType})`);
const {
buffer: body,
extension,
contentType,
} = await (attachment.type === 'video'
? this.mediaRepository.optimizeVideo(buffer)
: this.mediaRepository.optimizeImage(buffer));
const hash = this.systemRepository.md5(body);
const filename = `${hash}.${extension}`;
const key = `${bucketFolder}/${filename}`;
if (!existingKeys.has(key) && !referencedKeys.has(key)) {
await this.r2Repository.upload(key, body, contentType);
console.log(`Uploaded: ${key}`);
if (attachment.type === 'video') {
const { buffer: body, extension, contentType } = await this.mediaRepository.optimizeVideo(buffer);
const src = await upload(`${this.systemRepository.md5(body)}.${extension}`, body, contentType);
const markup = `<video autoplay${attributes({ src, title: cleanTitle(attachment.title) })} controls>Your browser does not support the video tag.</video>`;
attachment.update(markup);
continue;
}
referencedKeys.add(key);
const newUrl = `${r2.publicUrl}/${key}`;
const variants = await this.mediaRepository.optimizeImage(buffer);
const widest = variants.at(-1)!;
// An image has no single output to hash, and libaom is not byte-stable across thread counts.
const hash = this.systemRepository.md5(Buffer.concat([Buffer.from(PIPELINE_VERSION), buffer]));
const candidates: { url: string; width: number }[] = [];
for (const { buffer: body, width, extension } of variants) {
const url = await upload(`${hash}-${width}.${extension}`, body, `image/${extension}`);
candidates.push({ url, width });
}
const image = {
src: candidates.at(-1)!.url,
// A lone candidate would still take its intrinsic width from `sizes` and upscale.
srcset: candidates.length > 1 ? candidates.map(({ url, width }) => `${url} ${width}w`).join(', ') : undefined,
width: widest.width,
height: widest.height,
};
if (firstAsCover && !cover) {
cover = { url: newUrl, alt: attachment.alt };
cover = {
coverUrl: image.src,
coverSrcset: image.srcset,
coverWidth: image.width,
coverHeight: image.height,
coverAlt: attachment.alt,
};
attachment.remove();
} else {
attachment.update(newUrl, cleanTitle(attachment.title));
attachment.update(
`<Markdown.Image${attributes({ ...image, alt: attachment.alt, title: cleanTitle(attachment.title) })} />`,
);
}
}
// remark escapes `<` in text as `\<`; keep it bare to match the source
const rendered = markdown.stringify(document).replaceAll(String.raw`\<`, '<');
const metadata: Record<string, unknown> = { ...data, id: uuid, title, slug, authors: ['Immich Team'] };
const metadata: Record<string, unknown> = { ...data, id: uuid, title, slug, authors: ['Immich Team'], ...cover };
if (metadata.publishedAt instanceof Date) {
metadata.publishedAt = metadata.publishedAt.toISOString().slice(0, 10);
}
@@ -158,18 +198,11 @@ export class ImportService {
metadata.publishedAt = new Date().toISOString().slice(0, 10);
}
if (cover) {
metadata.coverUrl = cover.url;
metadata.coverAlt = cover.alt;
}
const staleKeys = existingKeys.difference(referencedKeys);
// Nothing is deleted: the deployed markdown points at the old keys until this import ships.
const uploadedKeys = referencedKeys.difference(existingKeys);
const unchanged = referencedKeys.size - uploadedKeys.size;
console.log(`\nBucket stats (uploaded=${uploadedKeys.size}, unchanged=${unchanged}, deleted=${staleKeys.size})`);
await this.r2Repository.deleteKeys([...staleKeys]);
console.log(`\nBucket stats (uploaded=${uploadedKeys.size}, unchanged=${referencedKeys.size - uploadedKeys.size})`);
const outputFile = join(repoRoot, 'apps/root.immich.app/src/routes/blog', folder, '+page.md');
const outputFile = join(repoRoot, 'apps/root.immich.app', outputRelative);
this.systemRepository.write(outputFile, serializeYml(metadata, rendered));
this.systemRepository.format(repoRoot, outputFile);
@@ -193,10 +226,7 @@ export class ImportService {
type: 'image',
alt: node.alt ?? '',
title: node.title,
update: (url, title) => {
node.title = title;
node.url = url;
},
update: (markup) => this.replaceNode(parent, node, markup),
remove: () => this.removeNode(document, parent, node),
});
});
@@ -211,17 +241,7 @@ export class ImportService {
type: 'video',
alt: '',
title: node.title,
update: (src, title) => {
const titleAttr = title ? ` title="${title}"` : '';
const html: Html = {
type: 'html',
value: `<video autoplay src="${src}"${titleAttr} controls>Your browser does not support the video tag.</video>`,
};
const at = parent.children.indexOf(node);
if (at !== -1) {
parent.children[at] = html;
}
},
update: (markup) => this.replaceNode(parent, node, markup),
remove: () => this.removeNode(document, parent, node),
});
});
@@ -229,6 +249,10 @@ export class ImportService {
return attachments;
}
private replaceNode(parent: Parent, node: Image | Link, markup: string): void {
parent.children[parent.children.indexOf(node)] = { type: 'html', value: markup };
}
private removeNode(document: MarkdownDocument, parent: Parent, node: Image | Link): void {
const index = parent.children.indexOf(node);
if (index !== -1) {
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { staleKeys } from './stale-keys.js';
const PUBLIC_URL = 'https://static.immich.cloud';
const CDN = `${PUBLIC_URL}/blog/post-1`;
const POST = `---
coverSrcset: ${CDN}/cover-720.avif
720w, ${CDN}/cover-2160.avif
2160w
coverUrl: ${CDN}/cover-2160.avif
---
<Markdown.Image src="${CDN}/bird-1080.avif" srcset="${CDN}/bird-720.avif 720w, ${CDN}/bird-1080.avif 1080w" />
![Old](${CDN}/legacy.webp 'left-50 =250x525')
<video autoplay src="${CDN}/clip.mp4" controls></video>
`;
const OLD = new Date('2020-01-01');
describe('staleKeys', () => {
it('returns objects no post references, unless they were uploaded recently', () => {
const names = ['cover-720.avif', 'cover-2160.avif', 'bird-720.avif', 'bird-1080.avif', 'legacy.webp', 'clip.mp4'];
const objects = [
...names.map((name) => ({ key: `blog/post-1/${name}`, lastModified: OLD })),
{ key: 'blog/post-1/stale.webp', lastModified: OLD },
{ key: 'blog/post-2/pending-720.avif', lastModified: new Date() },
];
expect(staleKeys(POST, PUBLIC_URL, objects)).toEqual(['blog/post-1/stale.webp']);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { CLEANUP_GRACE_DAYS } from './constants.js';
import type { BucketObject } from './types.js';
export const staleKeys = (posts: string, publicUrl: string, objects: BucketObject[]): string[] => {
const reference = new RegExp(String.raw`${RegExp.escape(publicUrl)}/(blog/[\w./-]+)`, 'g');
const referenced = new Set(posts.matchAll(reference).map((match) => match[1]));
const cutoff = Date.now() - CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000;
return objects
.filter(({ key, lastModified }) => !referenced.has(key) && lastModified.getTime() < cutoff)
.map(({ key }) => key);
};
+5 -1
View File
@@ -25,8 +25,12 @@ export type ParsedDocument = {
content: string;
};
export type BucketObject = { key: string; lastModified: Date };
export type OptimizeResult = { buffer: Buffer; extension: string; contentType: string };
export type ImageVariant = { buffer: Buffer; width: number; height: number; extension: 'avif' | 'webp' };
export type MarkdownDocument = Root;
export type AttachmentType = 'image' | 'video';
@@ -36,6 +40,6 @@ export type OutlineAttachment = {
type: AttachmentType;
alt: string;
title: string | null | undefined;
update(url: string, title: string | null | undefined): void;
update(markup: string): void;
remove(): void;
};
+1
View File
@@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['{src,test}/**/*.{test,spec}.{js,ts}'],
testTimeout: 30_000, // the media specs run the real encoders
},
});
@@ -26,6 +26,9 @@ const renderAlert = (
tokens: Token[],
) => `<Markdown.Alert${createAttributes({ variant, title })}>${parser.parse(tokens)}</Markdown.Alert>\n`;
// Marked reads a dotted tag as text, so a component like `<Markdown.Image />` needs its own rule.
const SVELTE_COMPONENT_REGEX = /^<[A-Z]\w*\.\w+\b[^>]*\/>[ \t]*(?:\n+|$)/;
const normalizeText = (text: string) => escapeHtml(emojify(text));
export const markedSvelte = (): MarkedExtension => ({
@@ -70,6 +73,11 @@ export const markedSvelte = (): MarkedExtension => ({
return false;
}
},
html(src) {
const match = SVELTE_COMPONENT_REGEX.exec(src);
return match ? { type: 'html', raw: match[0], text: `${match[0].trim()}\n`, pre: false, block: true } : false;
},
},
extensions: [
@@ -0,0 +1,36 @@
import Image from '$lib/components/Markdown/Image.svelte';
import { IMAGE_SIZES_QUERY } from '$lib/utilities/image-sizes.js';
import type { ComponentProps } from 'svelte';
import { render } from 'svelte/server';
import { describe, expect, it } from 'vitest';
const html = (props: ComponentProps<typeof Image>) => render(Image, { props }).body;
describe('Markdown.Image', () => {
it('serves the srcset the post declares, sized against the article column', () => {
const srcset = 'https://cdn/x-720.avif 720w, https://cdn/x-2160.avif 2160w';
const body = html({ src: 'https://cdn/x-2160.avif', srcset, width: 2160, height: 1440 });
expect(body).toContain('src="https://cdn/x-2160.avif"');
expect(body).toContain(`srcset="${srcset}"`);
expect(body).toContain(`sizes="${IMAGE_SIZES_QUERY}"`);
expect(body).toContain('width="2160"');
expect(body).toContain('height="1440"');
});
it('renders a bare img when the post declares no variants', () => {
const body = html({ src: 'https://cdn/x.webp' });
expect(body).toContain('src="https://cdn/x.webp"');
expect(body).not.toContain('srcset');
expect(body).not.toContain('sizes');
});
it('loads lazily unless marked as priority', () => {
expect(html({ src: 'https://cdn/x.webp' })).toContain('loading="lazy"');
const priority = html({ src: 'https://cdn/x.webp', priority: true });
expect(priority).toContain('loading="eager"');
expect(priority).toContain('fetchpriority="high"');
});
});
@@ -1,23 +1,34 @@
<script lang="ts">
import { IMAGE_SIZES_QUERY } from '$lib/utilities/image-sizes.js';
import { cleanClass } from '$lib/utilities/internal.js';
import type { Snippet } from 'svelte';
type Props = {
src: string;
srcset?: string;
width?: number | string;
height?: number | string;
alt?: string;
caption?: Snippet;
title?: string;
class?: string;
priority?: boolean;
};
const { src, alt, caption, title, class: className }: Props = $props();
const { src, srcset, width, height, alt, caption, title, class: className, priority }: Props = $props();
</script>
<figure class="my-3">
<img
{src}
{srcset}
sizes={srcset ? IMAGE_SIZES_QUERY : undefined}
{width}
{height}
{alt}
{title}
loading={priority ? 'eager' : 'lazy'}
fetchpriority={priority ? 'high' : undefined}
class={cleanClass('rounded-lg object-cover block max-w-full max-h-[80vh] w-auto h-auto mx-auto', className)}
/>
{#if caption || alt}
+1
View File
@@ -123,6 +123,7 @@ export * from '$lib/state/persisted.js';
export * from '$lib/types.js';
export * from '$lib/utilities/byte-units.js';
export * from '$lib/utilities/common.js';
export * from '$lib/utilities/image-sizes.js';
// site
export * from '$lib/site/constants.js';
@@ -0,0 +1,6 @@
const THEME_ROOT_PX = 17;
const COLUMN_PX = 42 * THEME_ROOT_PX; // Tailwind `max-w-2xl`
const GUTTER_PX = 2 * THEME_ROOT_PX; // `PageContent` padding
// Rem in a media query resolves against the browser's root size, not the theme's.
export const IMAGE_SIZES_QUERY = `(min-width: ${COLUMN_PX + GUTTER_PX}px) ${COLUMN_PX}px, calc(100vw - ${GUTTER_PX}px)`;