mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
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:
@@ -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
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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
|
||||
`,
|
||||
});
|
||||
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
|
||||
`,
|
||||
});
|
||||
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('');
|
||||
|
||||
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('');
|
||||
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('');
|
||||
|
||||
await sut.run(POST_URL);
|
||||
|
||||
expect(body()).toContain('alt="The {count} 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> = {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'{': '{',
|
||||
'}': '}',
|
||||
};
|
||||
|
||||
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" />
|
||||
|
||||

|
||||
|
||||
<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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)`;
|
||||
Reference in New Issue
Block a user