@lettercamp/cli 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { imageOptimization } from "./image-optimization.js";
1
2
  import { publicationLinks } from './publication-links.js';
2
3
  import { codeBlocks } from "./code-highlight.js";
3
4
  import { contentComponents } from "./content-component-syntax.js";
@@ -38,10 +39,12 @@ export async function buildSite(directory, options) {
38
39
  root: workspace, configFile: false, site: origin.origin, base: origin.pathname, trailingSlash: 'always',
39
40
  cacheDir: path.join(workspace, '.cache'),
40
41
  publicDir: path.join(workspace, '.lettercamp-no-public'), outDir, output: 'static',
41
- integrations: [mdx(), react()],
42
+ integrations: [mdx(), react(), imageOptimization(workspace)],
42
43
  vite: reactViteConfig,
43
44
  markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, searchContent, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
44
- build: { format: 'directory', ...(options.assetsPrefix ? { assetsPrefix: options.assetsPrefix } : {}) },
45
+ // Ship the publication's CSS with the document, avoiding a render-blocking
46
+ // HTML -> stylesheet round trip on every cold landing.
47
+ build: { format: 'directory', inlineStylesheets: 'always', ...(options.assetsPrefix ? { assetsPrefix: options.assetsPrefix } : {}) },
45
48
  });
46
49
  return outDir;
47
50
  }
package/dist/cli.js CHANGED
@@ -28,9 +28,10 @@ export async function run(argv = process.argv) {
28
28
  program.command('new')
29
29
  .description('Download the starter into a new or empty directory')
30
30
  .argument('[directory]', 'Destination directory', 'my-lettercamp')
31
- .action(async (directory) => {
31
+ .option('--preset <name>', 'Apply a starter preset, such as dark')
32
+ .action(async (directory, options) => {
32
33
  const { createSite } = await import('./new.js');
33
- await createSite(directory);
34
+ await createSite(directory, options);
34
35
  });
35
36
  program.command('deploy')
36
37
  .description('Publish this folder; create or connect a site on first use')
@@ -40,10 +40,10 @@ export const codeBlockStyles = `
40
40
  [data-lettercamp-code] .tok-invalid, [data-lettercamp-code] .tok-deleted { color: color-mix(in srgb, #aa4747, var(--site-foreground, #000000) var(--site-code-tint, 0%)); }
41
41
  [data-lettercamp-code] .tok-strong { font-weight: 600; }
42
42
  [data-lettercamp-code] .tok-emphasis { font-style: italic; }
43
- figure[data-lettercamp-code] > pre { box-sizing: border-box; margin: 0; padding: 16px 0; overflow-x: auto; border-radius: 8px; background: var(--site-surface, #fafafa); color: var(--site-text-strong, #414146); font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
43
+ figure[data-lettercamp-code] > pre { --code-comment: var(--site-text-on-surface, #6c6c6c); box-sizing: border-box; margin: 0; padding: 16px 0; overflow-x: auto; border-radius: 8px; background: var(--site-surface, #fafafa); color: var(--site-text-strong, #414146); font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
44
44
  figure[data-lettercamp-code] > pre > code { display: block; width: max-content; min-width: 100%; padding: 0; background: transparent; color: inherit; font: inherit; white-space: normal; counter-reset: code-line; }
45
45
  [data-lettercamp-code] .code-line { position: relative; display: block; box-sizing: border-box; min-height: 1.7em; padding: 0 18px 0 calc(var(--code-gutter, 3ch) + 24px); white-space: pre; }
46
- [data-lettercamp-code] .code-line::before { position: absolute; left: 8px; width: var(--code-gutter, 3ch); text-align: right; content: counter(code-line); counter-increment: code-line; color: var(--site-text-faint, #b4b4bc); user-select: none; font-variant-numeric: tabular-nums; }
46
+ [data-lettercamp-code] .code-line::before { position: absolute; left: 8px; width: var(--code-gutter, 3ch); text-align: right; content: counter(code-line); counter-increment: code-line; color: var(--site-text-on-surface, #6c6c6c); user-select: none; font-variant-numeric: tabular-nums; }
47
47
  [data-lettercamp-code][data-code-wrap="true"] > pre > code { width: 100%; }
48
48
  [data-lettercamp-code][data-code-wrap="true"] .code-line { white-space: pre-wrap; overflow-wrap: anywhere; }
49
49
  figure[data-lettercamp-code] > figcaption { margin: 8px 0 0; color: var(--site-text-muted, #888); font: 13px/1.5 var(--font-family, inherit); text-align: center; overflow-wrap: anywhere; }
package/dist/dev.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { imageOptimization } from "./image-optimization.js";
1
2
  import { codeBlocks } from "./code-highlight.js";
2
3
  import { contentComponents } from "./content-component-syntax.js";
3
4
  import { componentBlocks } from "./component-blocks.js";
@@ -54,7 +55,7 @@ export async function startDev(directory, options) {
54
55
  base: options.base ?? '/',
55
56
  publicDir: path.join(workspace, '.lettercamp-no-public'),
56
57
  output: 'static',
57
- integrations: [mdx(), react(), ...(options.middleware ? [{
58
+ integrations: [mdx(), react(), imageOptimization(workspace), ...(options.middleware ? [{
58
59
  name: 'lettercamp-editor-preview',
59
60
  hooks: { 'astro:config:setup': ({ addMiddleware }) => {
60
61
  addMiddleware({ entrypoint: options.middleware, order: 'post' });
@@ -0,0 +1,11 @@
1
+ import path from 'node:path';
2
+ // Run after every page has rendered: includes Markdown, MDX, custom components,
3
+ // and standard templates, without changing authored image URLs.
4
+ export function imageOptimization(workspace) {
5
+ return {
6
+ name: 'lettercamp-image-variants',
7
+ hooks: { 'astro:config:setup': ({ addMiddleware }) => {
8
+ addMiddleware({ entrypoint: path.join(workspace, 'src/lib/image-middleware.ts'), order: 'post' });
9
+ } },
10
+ };
11
+ }
package/dist/new.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises';
1
+ import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { downloadTemplate } from 'giget';
@@ -15,7 +15,39 @@ async function ensureEmpty(directory) {
15
15
  if (entries.length)
16
16
  throw new Error(`Directory is not empty: ${directory}. Choose a new or empty directory.`);
17
17
  }
18
- export async function createSite(directory) {
18
+ function isObject(value) {
19
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+ // Merge nested settings while replacing arrays and scalar values in full.
22
+ function mergeSettings(base, overrides) {
23
+ return Object.fromEntries([...new Set([...Object.keys(base), ...Object.keys(overrides)])].map(key => {
24
+ if (!Object.hasOwn(overrides, key))
25
+ return [key, base[key]];
26
+ const value = overrides[key];
27
+ return [key, isObject(base[key]) && isObject(value) ? mergeSettings(base[key], value) : value];
28
+ }));
29
+ }
30
+ async function applyPreset(directory, name) {
31
+ const presets = path.join(directory, 'presets');
32
+ const available = (await readdir(presets).catch(error => {
33
+ if (error.code === 'ENOENT')
34
+ return [];
35
+ throw error;
36
+ })).filter(file => /^[a-z0-9][a-z0-9-]*\.json$/.test(file)).map(file => file.slice(0, -5)).sort();
37
+ if (!available.includes(name)) {
38
+ throw new Error(`Unknown preset "${name}". Available presets: ${available.join(', ') || 'none'}. Omit --preset to use the default starter.`);
39
+ }
40
+ const preset = JSON.parse(await readFile(path.join(presets, `${name}.json`), 'utf8'));
41
+ if (!isObject(preset) || !isObject(preset.settings))
42
+ throw new Error(`Preset "${name}" must contain a settings object.`);
43
+ const settingsPath = path.join(directory, 'settings.json');
44
+ const settings = JSON.parse(await readFile(settingsPath, 'utf8'));
45
+ await writeFile(settingsPath, JSON.stringify(mergeSettings(settings, preset.settings), null, 2) + '\n');
46
+ }
47
+ export async function createSite(directory, options = {}) {
48
+ if (options.preset !== undefined && !/^[a-z0-9][a-z0-9-]*$/.test(options.preset)) {
49
+ throw new Error('Use a preset name containing lowercase letters, numbers, and hyphens, such as dark.');
50
+ }
19
51
  const destination = path.resolve(directory);
20
52
  await ensureEmpty(destination);
21
53
  const temporary = await mkdtemp(path.join(tmpdir(), 'lettercamp-new-'));
@@ -26,6 +58,10 @@ export async function createSite(directory) {
26
58
  force: true,
27
59
  preferOffline: false,
28
60
  });
61
+ if (options.preset !== undefined)
62
+ await applyPreset(temporary, options.preset);
63
+ // Presets are creation inputs, not part of the user's publication.
64
+ await rm(path.join(temporary, 'presets'), { recursive: true, force: true });
29
65
  // Download first, so a failed request does not leave a half-created site.
30
66
  await ensureEmpty(destination);
31
67
  await mkdir(destination, { recursive: true });
package/dist/upload.js CHANGED
@@ -8,11 +8,11 @@ async function uploadFile(file, slug, token) {
8
8
  const info = await handle.stat();
9
9
  if (!info.isFile() || !info.size)
10
10
  throw new Error('Choose a nonempty image or font file.');
11
- if (info.size > 10 * 1024 * 1024)
12
- throw new Error('Files must be 10 MB or smaller.');
11
+ if (info.size > 100 * 1024 * 1024)
12
+ throw new Error('Files must be 100 MB or smaller.');
13
13
  bytes = await handle.readFile();
14
- if (bytes.byteLength > 10 * 1024 * 1024)
15
- throw new Error('Files must be 10 MB or smaller.');
14
+ if (bytes.byteLength > 100 * 1024 * 1024)
15
+ throw new Error('Files must be 100 MB or smaller.');
16
16
  }
17
17
  finally {
18
18
  await handle.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lettercamp/cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "The repository for the Lettercamp CLI",
5
5
  "keywords": [
6
6
  "lettercamp",
@@ -8,7 +8,6 @@ const { site, category = '' } = Astro.props;
8
8
  const allPosts = getPosts(site.language, site.timeZone);
9
9
  const posts = allPosts.filter(post => !category || post.category === category);
10
10
  const categories = getCategories();
11
- const hasCategories = categories.length > 0;
12
11
  const featuredLimit = 9;
13
12
  ---
14
13
 
@@ -18,17 +17,17 @@ const featuredLimit = 9;
18
17
  {site.description && <p class="description" style="margin-top: 12px">{site.description}</p>}
19
18
  </header>
20
19
  <div class="blog-toolbar">
21
- {hasCategories && <nav class="category-selector" aria-label="Filter posts by category" hidden={site.home?.showCategories === false}>
20
+ {categories.length > 0 ? <nav class="category-selector" aria-label="Filter posts by category">
22
21
  <a href={sitePath("/")} data-category="" aria-current={!category ? "page" : undefined}>All</a>
23
22
  {categories.map(item => <a href={item.href} data-category={item.name} aria-current={category === item.name ? "page" : undefined}>{item.name}</a>)}
24
- </nav>}
23
+ </nav> : <p class="post-count">{allPosts.length} posts</p>}
25
24
  <PostSearch posts={allPosts} />
26
25
  </div>
27
26
  <p class="sr-only" data-results-count role="status" aria-live="polite">{posts.length} articles</p>
28
27
  <section aria-label="Recent articles">
29
28
  <h2 class="sr-only">Recent articles</h2>
30
29
  <div class="post-list" data-featured-posts>
31
- {posts.slice(0, featuredLimit).map(post => <PostCard post={post} site={site} />)}
30
+ {posts.slice(0, featuredLimit).map((post, index) => <PostCard post={post} site={site} priority={index === 0} />)}
32
31
  </div>
33
32
  <p class="empty-state" data-empty-state hidden={posts.length > 0}>No articles here yet.</p>
34
33
  </section>
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  import AuthorByline from './AuthorByline.astro';
3
- const { post, site } = Astro.props;
3
+ const { post, site, priority = false } = Astro.props;
4
4
  const image = post.ogImage ?? post.image ?? site.seo?.ogImage;
5
5
  ---
6
6
 
7
7
  <article class="post-card" data-post-category={post.category}>
8
8
  <div class="post-card-layout">
9
9
  <div class="post-card-cover">
10
- {image ? <img src={image} alt="" width="1200" height="630" loading="lazy" /> : <span class="image-placeholder" aria-hidden="true">{site.name}</span>}
10
+ {image ? <img src={image} alt="" width="1200" height="630" loading={priority ? 'eager' : 'lazy'} fetchpriority={priority ? 'high' : undefined} /> : <span class="image-placeholder" aria-hidden="true">{site.name}</span>}
11
11
  </div>
12
12
  <div class="post-card-content">
13
13
  {(post.category || post.date?.label) && <div class="post-card-details">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { LuSearch } from 'react-icons/lu';
2
3
  import '../styles/post-search.css';
3
4
  import { postSearchScript } from '../lib/post-search';
4
5
  const { posts } = Astro.props;
@@ -14,12 +15,12 @@ const index = JSON.stringify(posts.map(post => ({
14
15
  ---
15
16
 
16
17
  <div class="post-search-trigger" hidden>
17
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5" /></svg>
18
- <button type="button" aria-label="Search articles" aria-haspopup="dialog" aria-controls="post-search-dialog">Search articles…</button>
18
+ <LuSearch aria-hidden="true" />
19
+ <button type="button" aria-label="Search articles" aria-haspopup="dialog" aria-controls="post-search-dialog"><span>Search articles…</span></button>
19
20
  </div>
20
21
  <dialog id="post-search-dialog" class="post-search-dialog" aria-label="Search articles">
21
22
  <div class="post-search-header">
22
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5" /></svg>
23
+ <LuSearch aria-hidden="true" />
23
24
  <input type="text" role="searchbox" enterkeyhint="search" placeholder="Search articles…" aria-label="Search article titles and content" aria-controls="post-search-results" autocomplete="off" spellcheck="false" autofocus />
24
25
  <button type="button" class="post-search-close" aria-label="Close search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg></button>
25
26
  </div>
@@ -21,8 +21,8 @@ import { googleAnalyticsScript } from '../lib/analytics';
21
21
  import Header from '../components/HeaderSlot.astro';
22
22
  import Footer from '../components/FooterSlot.astro';
23
23
 
24
- const { site, page, article = false } = Astro.props;
25
- const meta = pageMetadata(site, page, Astro.url, article, basePath);
24
+ const { site, page, article = false, posts = [], priorityImage } = Astro.props;
25
+ const meta = pageMetadata(site, page, Astro.url, article, basePath, posts);
26
26
  const typography = site.design?.typography ?? {};
27
27
  const fonts = fontResources(typography, article ? 700 : 500);
28
28
  const fontSize = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
@@ -38,6 +38,7 @@ const analytics = googleAnalyticsScript(site.analytics?.googleAnalyticsId);
38
38
  <head>
39
39
  <meta charset="utf-8" />
40
40
  <meta name="viewport" content="width=device-width, initial-scale=1" />
41
+ {priorityImage && <link rel="preload" as="image" href={priorityImage} fetchpriority="high" />}
41
42
  {fonts.preloads.map(href => <link data-lettercamp-font-preload rel="preload" href={href} as="font" type="font/woff2" crossorigin />)}
42
43
  {fonts.googleUrl && <>
43
44
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -69,7 +69,8 @@ export const authorDialogScript = `
69
69
  portrait.setAttribute('aria-hidden', 'true');
70
70
  if (author.image) {
71
71
  const image = element('img', 'author-avatar');
72
- image.src = author.image; image.alt = ''; image.width = 28; image.height = 28;
72
+ if (author.imageAttributes) Object.assign(image, author.imageAttributes);
73
+ else image.src = author.image; image.alt = ''; image.width = 28; image.height = 28;
73
74
  portrait.append(image);
74
75
  } else {
75
76
  portrait.append(element('span', 'author-initials', author.name.trim().split(/\\s+/).map(part => part[0]).slice(0, 2).join('')));
@@ -36,10 +36,10 @@ export const codeBlockStyles = `
36
36
  [data-lettercamp-code] .tok-invalid, [data-lettercamp-code] .tok-deleted { color: color-mix(in srgb, #aa4747, var(--site-foreground, #000000) var(--site-code-tint, 0%)); }
37
37
  [data-lettercamp-code] .tok-strong { font-weight: 600; }
38
38
  [data-lettercamp-code] .tok-emphasis { font-style: italic; }
39
- figure[data-lettercamp-code] > pre { box-sizing: border-box; margin: 0; padding: 16px 0; overflow-x: auto; border-radius: 8px; background: var(--site-surface, #fafafa); color: var(--site-text-strong, #414146); font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
39
+ figure[data-lettercamp-code] > pre { --code-comment: var(--site-text-on-surface, #6c6c6c); box-sizing: border-box; margin: 0; padding: 16px 0; overflow-x: auto; border-radius: 8px; background: var(--site-surface, #fafafa); color: var(--site-text-strong, #414146); font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
40
40
  figure[data-lettercamp-code] > pre > code { display: block; width: max-content; min-width: 100%; padding: 0; background: transparent; color: inherit; font: inherit; white-space: normal; counter-reset: code-line; }
41
41
  [data-lettercamp-code] .code-line { position: relative; display: block; box-sizing: border-box; min-height: 1.7em; padding: 0 18px 0 calc(var(--code-gutter, 3ch) + 24px); white-space: pre; }
42
- [data-lettercamp-code] .code-line::before { position: absolute; left: 8px; width: var(--code-gutter, 3ch); text-align: right; content: counter(code-line); counter-increment: code-line; color: var(--site-text-faint, #b4b4bc); user-select: none; font-variant-numeric: tabular-nums; }
42
+ [data-lettercamp-code] .code-line::before { position: absolute; left: 8px; width: var(--code-gutter, 3ch); text-align: right; content: counter(code-line); counter-increment: code-line; color: var(--site-text-on-surface, #6c6c6c); user-select: none; font-variant-numeric: tabular-nums; }
43
43
  [data-lettercamp-code][data-code-wrap="true"] > pre > code { width: 100%; }
44
44
  [data-lettercamp-code][data-code-wrap="true"] .code-line { white-space: pre-wrap; overflow-wrap: anywhere; }
45
45
  figure[data-lettercamp-code] > figcaption { margin: 8px 0 0; color: var(--site-text-muted, #888); font: 13px/1.5 var(--font-family, inherit); text-align: center; overflow-wrap: anywhere; }
@@ -0,0 +1,135 @@
1
+ import { fromHtml } from 'hast-util-from-html';
2
+ import { toHtml } from 'hast-util-to-html';
3
+ import type { Element, Root, RootContent } from 'hast';
4
+ import { IMAGE_ORIGIN, imageAttributes, imageAsset, type ImageManifest } from './image-variants';
5
+ import { readImageManifest } from './image-manifest';
6
+
7
+ const classes = (node: Element) => Array.isArray(node.properties.className) ? node.properties.className : [];
8
+ const has = (nodes: Element[], name: string) => nodes.some(node => classes(node).includes(name));
9
+ const viewport = (max: number) => `(max-width: 600px) min(${max}px, calc(100vw - 32px)), (max-width: 900px) min(${max}px, calc(100vw - 48px)), min(${max}px, calc(100vw - 64px))`;
10
+
11
+ function managedImage(node: Element, parents: Element[]) {
12
+ if (node.tagName !== 'img' || typeof node.properties.src !== 'string' || node.properties.srcSet) return false;
13
+ // Hydrated components own their DOM and props. Rewriting their server HTML
14
+ // would disagree with the client render and can restore the original URL.
15
+ if (parents.some(parent => parent.tagName === 'astro-island')) return false;
16
+ return !parents.some(parent => parent.tagName === 'picture' && parent.children.some(child => child.type === 'element' && child.tagName === 'source'));
17
+ }
18
+
19
+ function sizesFor(node: Element, parents: Element[]) {
20
+ const props = node.properties;
21
+ if (typeof props.sizes === 'string') return props.sizes;
22
+ if (has([...parents, node], 'author-portrait')) return '24px';
23
+ if (has(parents, 'brand')) return `${Number(props.width) || 30}px`;
24
+ if (has(parents, 'post-card-cover')) {
25
+ const home = parents.find(parent => classes(parent).includes('blog-home'));
26
+ const max = Number(String(home?.properties.style).match(/--home-content-width:\s*([\d.]+)px/)?.[1]) || 1280;
27
+ const sizes = [];
28
+ // The site's container queries use 552/852px; viewport gutters also change.
29
+ for (const [limit, gutter] of [[600, 32], [900, 48], [100000, 64]]) {
30
+ const container = `min(${max}px, calc(100vw - ${gutter}px))`;
31
+ if (home?.properties.dataHomeLayout === 'rows') {
32
+ sizes.push(max <= 552 || limit === 600
33
+ ? `(max-width: ${limit}px) calc(${container} - 2px)`
34
+ : `(max-width: ${limit}px) calc((${container} - 32px) * .43 - 2px)`);
35
+ } else {
36
+ if (max <= 552) sizes.push(`(max-width: ${limit}px) calc(${container} - 2px)`);
37
+ else {
38
+ sizes.push(`(max-width: ${Math.min(limit, 552 + gutter)}px) calc(${container} - 2px)`);
39
+ if (max <= 852) sizes.push(`(max-width: ${limit}px) calc((${container} - 32px) / 2 - 2px)`);
40
+ else {
41
+ sizes.push(`(max-width: ${Math.min(limit, 852 + gutter)}px) calc((${container} - 32px) / 2 - 2px)`);
42
+ sizes.push(`(max-width: ${limit}px) calc((${container} - 96px) / 3 - 2px)`);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return sizes.join(', ') + ', 400px';
48
+ }
49
+ const hero = parents.find(parent => classes(parent).includes('article-hero-image'));
50
+ if (hero) {
51
+ const article = parents.find(parent => classes(parent).includes('publication-post'));
52
+ const width = Number(article?.properties.dataImageContentWidth) || 640;
53
+ const mode = hero.properties.dataImageWidth;
54
+ const fixed = Number(String(hero.properties.style).match(/--cover-image-width:\s*([\d.]+)px/)?.[1]);
55
+ const max = mode === 'full' ? 1280 : mode === 'regular' ? Math.min(fixed || width, width) : width + 128;
56
+ if (article?.properties.dataPostHero === 'split') return `(max-width: 600px) min(${max}px, calc(100vw - 32px)), (max-width: 760px) min(${max}px, calc(100vw - 48px)), (max-width: 900px) min(${max}px, calc((100vw - 80px) / 2)), min(${max}px, calc((min(1280px, 100vw - 64px) - 64px) / 2))`;
57
+ return viewport(max);
58
+ }
59
+ // Lazy images use their actual layout size in supporting browsers, including
60
+ // custom blocks. The fallback supports older browsers without runtime JS.
61
+ return viewport(Number(props.width) || 800);
62
+ }
63
+
64
+ export async function optimizeImageHtml(html: string) {
65
+ if (!html.includes(`${IMAGE_ORIGIN}/`)) return html;
66
+ const tree = fromHtml(html);
67
+ const elements: { node: Element; parents: Element[] }[] = [];
68
+ function walk(node: Root | RootContent, parents: Element[] = []) {
69
+ if (node.type === 'element') elements.push({ node, parents });
70
+ if ('children' in node) for (const child of node.children) walk(child, node.type === 'element' ? [...parents, node] : parents);
71
+ }
72
+ walk(tree);
73
+ const urls = new Set<string>();
74
+ const authors = new Map<Element, { image?: string; imageAttributes?: unknown }[]>();
75
+ const indexes = new Map<Element, { image?: string; imageAttributes?: unknown }[]>();
76
+ const add = (url: unknown) => { if (typeof url === 'string' && imageAsset(url)) urls.add(url); };
77
+ for (const { node, parents } of elements) {
78
+ if (managedImage(node, parents)) add(node.properties.src);
79
+ if (node.tagName === 'button' && classes(node).includes('author-more') && typeof node.properties.dataAuthors === 'string') {
80
+ const data = JSON.parse(node.properties.dataAuthors);
81
+ authors.set(node, data); data.forEach((author: { image?: string }) => add(author.image));
82
+ }
83
+ if (node.tagName === 'script' && node.properties.dataPostSearchIndex !== undefined) {
84
+ const data = JSON.parse(node.children.map(child => child.type === 'text' ? child.value : '').join(''));
85
+ indexes.set(node, data); data.forEach((post: { image?: string }) => add(post.image));
86
+ }
87
+ }
88
+ if (!urls.size) return html;
89
+ const manifests = new Map<string, ImageManifest | null>();
90
+ const pending = [...urls];
91
+ // Bound build concurrency; repeated pages reuse the shared manifest cache.
92
+ await Promise.all(Array.from({ length: Math.min(8, pending.length) }, async () => {
93
+ for (;;) {
94
+ const url = pending.shift();
95
+ if (!url) break;
96
+ try { manifests.set(url, await readImageManifest(url)); }
97
+ catch { /* Variant metadata is optional; keep the original image available. */ }
98
+ }
99
+ }));
100
+ const priority = new Map<string, ReturnType<typeof imageAttributes>>();
101
+ for (const { node, parents } of elements) {
102
+ if (!managedImage(node, parents)) continue;
103
+ const original = node.properties.src as string;
104
+ const manifest = manifests.get(original);
105
+ const small = has(parents, 'brand') || has(parents, 'author-portrait');
106
+ const loading = node.properties.loading ?? (!small && node.properties.fetchPriority !== 'high' ? 'lazy' : undefined);
107
+ const specifiedSizes = sizesFor(node, parents);
108
+ const sizes = `${loading === 'lazy' && !/^auto(?:\s*,|$)/.test(specifiedSizes) ? 'auto, ' : ''}${specifiedSizes}`;
109
+ const attrs = imageAttributes(manifest, sizes, small ? 64 : 800);
110
+ if (!attrs) continue;
111
+ Object.assign(node.properties, attrs, { loading, decoding: 'async', dataImageOriginal: original });
112
+ if (manifest) {
113
+ const width = Number(node.properties.width), height = Number(node.properties.height);
114
+ if (!width && !height) Object.assign(node.properties, { width: manifest.width, height: manifest.height });
115
+ else if (width > 0 && !node.properties.height) node.properties.height = Math.max(1, Math.round(width * manifest.height / manifest.width));
116
+ else if (height > 0 && !node.properties.width) node.properties.width = Math.max(1, Math.round(height * manifest.width / manifest.height));
117
+ }
118
+ if (node.properties.fetchPriority === 'high') priority.set(original, attrs);
119
+ }
120
+ for (const { node } of elements) {
121
+ if (node.tagName === 'link' && node.properties.as === 'image') {
122
+ const attrs = priority.get(String(node.properties.href));
123
+ if (attrs) Object.assign(node.properties, { href: attrs.src, imageSrcSet: attrs.srcSet, imageSizes: attrs.sizes });
124
+ }
125
+ }
126
+ for (const [node, data] of authors) {
127
+ for (const author of data) if (author.image) author.imageAttributes = imageAttributes(manifests.get(author.image), '24px', 64);
128
+ node.properties.dataAuthors = JSON.stringify(data);
129
+ }
130
+ for (const [node, data] of indexes) {
131
+ for (const post of data) if (post.image) post.imageAttributes = imageAttributes(manifests.get(post.image), '64px', 400);
132
+ node.children = [{ type: 'text', value: JSON.stringify(data).replace(/</g, '\\u003c') }];
133
+ }
134
+ return toHtml(tree);
135
+ }
@@ -0,0 +1,26 @@
1
+ // Generated from lettercamp/lib/image-manifest.ts.
2
+ import { IMAGE_ORIGIN, imageAsset, type ImageManifest } from "./image-variants";
3
+
4
+ const cache = new Map<string, { expires: number; value: Promise<ImageManifest | null> }>();
5
+ export async function readImageManifest(url: string): Promise<ImageManifest | null> {
6
+ const asset = imageAsset(url);
7
+ if (!asset) return null;
8
+ const key = `${IMAGE_ORIGIN}/${asset.prefix}/manifest.json`;
9
+ const cached = cache.get(key);
10
+ if (cached && cached.expires > Date.now()) return cached.value;
11
+ if (cache.size >= 512) cache.delete(cache.keys().next().value!);
12
+ // Keep an in-flight request shared even when a slow response takes >2 seconds.
13
+ const entry = { expires: Infinity, value: Promise.resolve<ImageManifest | null>(null) };
14
+ entry.value = (async () => {
15
+ const response = await fetch(key, { credentials: "omit", headers: typeof window === "undefined" ? { "User-Agent": "Lettercamp/1.0" } : undefined, signal: AbortSignal.timeout(15_000), cache: "no-store" });
16
+ entry.expires = Date.now() + 2000;
17
+ if (response.status === 404) return null; // Older uploads remain usable until backfilled.
18
+ if (!response.ok) throw new Error(`Unable to read image variants (${response.status}).`);
19
+ const manifest = await response.json() as ImageManifest;
20
+ if (manifest.status === "ready" || manifest.status === "skipped") entry.expires = Date.now() + 300_000;
21
+ return manifest;
22
+ })();
23
+ cache.set(key, entry);
24
+ try { return await entry.value; }
25
+ catch (error) { if (cache.get(key) === entry) cache.delete(key); throw error; }
26
+ }
@@ -0,0 +1,11 @@
1
+ import { defineMiddleware } from 'astro:middleware';
2
+ import { optimizeImageHtml } from './image-html';
3
+
4
+ export const onRequest = defineMiddleware(async (_context, next) => {
5
+ const response = await next();
6
+ if (!response.headers.get('content-type')?.includes('text/html')) return response;
7
+ const html = await optimizeImageHtml(await response.text());
8
+ const headers = new Headers(response.headers);
9
+ headers.delete('content-length');
10
+ return new Response(html, { status: response.status, statusText: response.statusText, headers });
11
+ });
@@ -0,0 +1,68 @@
1
+ // Generated from lettercamp/lib/image-variants.ts.
2
+ // Shared by uploads, the editor, and the published renderer. Changing the recipe
3
+ // requires a new version: existing immutable objects must never change in place.
4
+ export const IMAGE_RECIPE = "v1";
5
+ export const IMAGE_WIDTHS = [64, 400, 800, 1600] as const;
6
+ export const IMAGE_ORIGIN = "https://assets.lettercamp.com";
7
+ export type ImageVariant = { url: string; width: number; height: number; bytes: number };
8
+ export type ImageManifest = {
9
+ version: typeof IMAGE_RECIPE; original: string; width: number; height: number;
10
+ status: "pending" | "ready" | "skipped" | "failed"; variants: ImageVariant[];
11
+ };
12
+
13
+ export function imageAsset(url: string) {
14
+ try {
15
+ const parsed = new URL(url);
16
+ if (parsed.origin !== IMAGE_ORIGIN || parsed.search || parsed.hash) return null;
17
+ const match = /^\/sites\/([a-f0-9-]{36})\/([a-f0-9-]{36})\.(jpg|jpeg|png|webp|gif)$/i.exec(parsed.pathname);
18
+ if (match) return { key: parsed.pathname.slice(1), siteId: match[1], id: match[2], prefix: `sites/${match[1]}/${match[2]}/${IMAGE_RECIPE}` };
19
+ // Shared originals keep their existing URLs. Include the full original key
20
+ // (and extension) so different source libraries cannot collide.
21
+ if (/^\/(?:starter\/v\d+\/(?:covers|authors)|system\/(?:branding|starter))\/[a-z0-9_./-]+\.(jpg|jpeg|png|webp|gif)$/i.test(parsed.pathname)) {
22
+ const key = parsed.pathname.slice(1);
23
+ return { key, siteId: null, id: key, prefix: `starter/assets/${IMAGE_RECIPE}/${key}` };
24
+ }
25
+ return null;
26
+ } catch { return null; }
27
+ }
28
+
29
+ export function imageWidths(width: number): number[] {
30
+ return [...new Set(IMAGE_WIDTHS.map(size => Math.min(size, width)))];
31
+ }
32
+
33
+ export function imageTransformOptions(width: number) {
34
+ return { width, fit: "scale-down" as const, format: "webp" as const, quality: "high" as const, metadata: "none" as const };
35
+ }
36
+
37
+ export function imageAttributes(manifest: ImageManifest | null | undefined, sizes: string, fallbackWidth = 800) {
38
+ if (manifest?.status !== "ready" || !manifest.variants.length) return null;
39
+ const variants = manifest.variants;
40
+ const fallback = variants.find(variant => variant.width >= fallbackWidth) ?? variants[variants.length - 1];
41
+ return { sizes, srcSet: variants.map(variant => `${variant.url} ${variant.width}w`).join(", "), src: fallback.url };
42
+ }
43
+
44
+ // GIFs remain intact. Detect animated PNG/WebP by their container chunks, never
45
+ // by guessing whether an image is a photo, illustration, or screenshot.
46
+ export function animatedImage(bytes: Uint8Array, format: string): boolean {
47
+ if (format === "gif") return true;
48
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
49
+ const name = (offset: number) => String.fromCharCode(...bytes.subarray(offset, offset + 4));
50
+ if (format === "png") {
51
+ for (let offset = 8; offset + 12 <= bytes.length;) {
52
+ const length = view.getUint32(offset);
53
+ if (name(offset + 4) === "acTL") return true;
54
+ if (name(offset + 4) === "IDAT") return false;
55
+ offset += length + 12;
56
+ }
57
+ }
58
+ if (format === "webp") {
59
+ for (let offset = 12; offset + 8 <= bytes.length;) {
60
+ const length = view.getUint32(offset + 4, true);
61
+ if (name(offset) === "ANIM") return true;
62
+ if (name(offset) === "VP8X" && length > 0 && offset + 8 < bytes.length) return (bytes[offset + 8] & 2) !== 0;
63
+ if (name(offset) === "VP8 " || name(offset) === "VP8L") return false;
64
+ offset += 8 + length + (length % 2);
65
+ }
66
+ }
67
+ return format === "png" || format === "webp";
68
+ }
@@ -1,10 +1,26 @@
1
1
  // Generated from lettercamp/lib/page-metadata.ts.
2
- export function pageMetadata(site, page, url, article = false, basePath = "") {
2
+ const text = (value) => typeof value === "string" ? value.trim() : "";
3
+ function siteCover(site, posts) {
4
+ const timestamp = (value) => {
5
+ if (value && typeof value === "object" && "timestamp" in value && typeof value.timestamp === "number")
6
+ return value.timestamp;
7
+ const time = value ? new Date(String(value)).valueOf() : NaN;
8
+ return Number.isNaN(time) ? -Infinity : time;
9
+ };
10
+ const post = posts.filter(post => post.draft !== true && (text(post.image) || text(post.ogImage)))
11
+ .sort((a, b) => timestamp(b.date) - timestamp(a.date) || (a.href ?? "").localeCompare(b.href ?? ""))[0];
12
+ return post
13
+ ? { image: text(post.image) || text(post.ogImage), alt: (text(post.image) ? text(post.imageAlt) : text(post.ogImageAlt)) || text(post.title) }
14
+ : { image: text(site.logo), alt: text(site.name) };
15
+ }
16
+ export function pageMetadata(site, page, url, article = false, basePath = "", posts = []) {
3
17
  const seo = site.seo && typeof site.seo === "object" ? site.seo : {};
4
- const text = (value) => typeof value === "string" ? value.trim() : "";
18
+ const homepage = !article && new URL(url).pathname.replace(/\/+$/, "") === basePath.replace(/\/+$/, "");
19
+ // Homepage overrides belong to the blog root, not posts or category pages.
20
+ if (homepage)
21
+ page = { ...seo, ...page };
5
22
  const title = text(page.title) || text(site.name);
6
23
  const pageTitle = title === site.name ? title : `${title} — ${text(site.name)}`;
7
- const homepage = !article && new URL(url).pathname.replace(/\/+$/, "") === basePath.replace(/\/+$/, "");
8
24
  const documentTitle = text(page.metaTitle) || (homepage && text(seo.metaTitle)) || pageTitle;
9
25
  const description = text(page.metaDescription) || text(page.description) || text(site.description);
10
26
  const socialTitle = text(page.ogTitle) || (homepage && (text(seo.ogTitle) || text(seo.metaTitle))) || title;
@@ -21,8 +37,10 @@ export function pageMetadata(site, page, url, article = false, basePath = "") {
21
37
  return "";
22
38
  }
23
39
  };
24
- const imageUrl = resolveUrl(page.ogImage || page.image || seo.ogImage);
25
- const imageAlt = text(page.ogImageAlt) || (page.ogImage || page.image ? text(page.imageAlt) : text(seo.ogImageAlt));
40
+ const explicitImage = text(page.ogImage) || text(page.image) || text(seo.ogImage);
41
+ const fallback = homepage && !explicitImage ? siteCover(site, posts) : undefined;
42
+ const imageUrl = resolveUrl(explicitImage || fallback?.image);
43
+ const imageAlt = text(page.ogImageAlt) || (fallback ? fallback.alt : page.ogImage || page.image ? text(page.imageAlt) : text(seo.ogImageAlt));
26
44
  const twitterImageUrl = resolveUrl(page.twitterImage) || imageUrl;
27
45
  const language = text(page.language) || text(site.language) || "en";
28
46
  let locale = "";
@@ -81,7 +81,8 @@ export const postSearchScript = String.raw`(() => {
81
81
  link.href = post.href;
82
82
  if (post.image) {
83
83
  const image = element('img', 'post-search-image', '');
84
- image.src = post.image;
84
+ if (post.imageAttributes) Object.assign(image, post.imageAttributes);
85
+ else image.src = post.image;
85
86
  image.alt = '';
86
87
  image.width = 1200;
87
88
  image.height = 630;
@@ -4,16 +4,36 @@
4
4
  export function siteColors(design?: { backgroundColor?: unknown; accentColor?: unknown }): Record<string, string> {
5
5
  const [background, accent] = [design?.backgroundColor, design?.accentColor].map((value, index) =>
6
6
  typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : index === 0 ? "#ffffff" : "");
7
- const [text, accentText] = [background, accent || background].map(color => {
7
+ const luminance = (color: string) => {
8
8
  const linear = [1, 3, 5].map(offset => {
9
9
  const channel = parseInt(color.slice(offset, offset + 2), 16) / 255;
10
10
  return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
11
11
  });
12
- const luminance = linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
13
- return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#ffffff";
12
+ return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
13
+ };
14
+ const contrast = (a: string, b: string) => {
15
+ const [light, dark] = [luminance(a), luminance(b)].sort((a, b) => b - a);
16
+ return (light + 0.05) / (dark + 0.05);
17
+ };
18
+ const [text, accentText] = [background, accent || background].map(color => {
19
+ return contrast(color, "#000000") >= contrast(color, "#ffffff") ? "#000000" : "#ffffff";
14
20
  });
15
21
  const base = [1, 3, 5].map(offset => parseInt(background.slice(offset, offset + 2), 16));
16
22
  const target = text === "#000000" ? 0 : 255;
23
+ const blend = (amount: number) => "#" + base.map(channel => Math.round(channel + (target - channel) * amount).toString(16).padStart(2, "0")).join("");
24
+ // Test the rounded sRGB color, not a perceptual distance or rounded ratio.
25
+ // Keep the least contrast possible while still meeting WCAG AA for small text.
26
+ const readable = (amount: number, backgrounds: string[]) => {
27
+ const passes = (value: number) => backgrounds.every(bg => contrast(blend(value), bg) >= 4.5);
28
+ if (passes(amount)) return blend(amount);
29
+ let low = amount, high = 1;
30
+ for (let step = 0; step < 24; step++) {
31
+ const middle = (low + high) / 2;
32
+ if (passes(middle)) high = middle;
33
+ else low = middle;
34
+ }
35
+ return blend(high);
36
+ };
17
37
  // Reference grays on white. Retain each shade's distance along the
18
38
  // background-to-foreground scale; e.g. 250 preserves #fafafa on white.
19
39
  const shades: Record<string, number> = {
@@ -36,7 +56,25 @@ export function siteColors(design?: { backgroundColor?: unknown; accentColor?: u
36
56
  "--site-code-tint": target === 255 ? "45%" : "0%",
37
57
  };
38
58
  for (const [name, gray] of Object.entries(shades)) {
39
- colors[`--site-${name}`] = "#" + base.map(channel => Math.round(channel + (target - channel) * (1 - gray / 255)).toString(16).padStart(2, "0")).join("");
59
+ let amount = 1 - gray / 255;
60
+ // Midtone backgrounds may barely pass with black or white. Do not tint
61
+ // their surfaces so far that even the foreground can no longer pass.
62
+ if (name.startsWith("surface") && contrast(blend(amount), text) < 4.5) {
63
+ let low = 0, high = amount;
64
+ for (let step = 0; step < 24; step++) {
65
+ const middle = (low + high) / 2;
66
+ if (contrast(blend(middle), text) >= 4.5) low = middle;
67
+ else high = middle;
68
+ }
69
+ amount = low;
70
+ }
71
+ colors[`--site-${name}`] = blend(amount);
72
+ }
73
+ const surfaces = [background, colors["--site-surface"], colors["--site-surface-hover"], colors["--site-surface-active"]];
74
+ for (const name of ["text-strong", "text-secondary"]) {
75
+ colors[`--site-${name}`] = readable(1 - shades[name] / 255, surfaces);
40
76
  }
77
+ colors["--site-text-muted"] = colors["--site-text-faint"] = readable(0, [background]);
78
+ colors["--site-text-on-surface"] = readable(0, surfaces);
41
79
  return colors;
42
80
  }
@@ -36,8 +36,9 @@ const coverWidthMode = ['regular', 'wide', 'full'].includes(frontmatter.coverWid
36
36
  const coverWidth = /^[0-9]+$/.test(String(frontmatter.coverWidth)) && Number(frontmatter.coverWidth) > 0 ? `${frontmatter.coverWidth}px` : undefined;
37
37
  ---
38
38
 
39
- <SiteLayout site={site} page={frontmatter} article>
39
+ <SiteLayout site={site} page={frontmatter} article priorityImage={postLayout.showCover !== false ? coverImage : undefined}>
40
40
  <article class="publication publication-post" style={{ '--post-content-width': `calc(var(--text-base) * ${readContentWidthEm(postLayout)})` }} data-post-hero={postLayout.heroLayout ?? 'stacked'}
41
+ data-image-content-width={readContentWidthEm(postLayout) * (Number(site.design?.typography?.bodyBaseSizePx) || 16)}
41
42
  data-reading-minutes={frontmatter.readingMinutes}
42
43
  data-post-cover={postLayout.showCover === false ? 'hidden' : 'visible'} data-post-toc={postLayout.showTableOfContents === false ? 'hidden' : 'visible'}>
43
44
  <header class:list={['article-header', 'article-hero', { 'article-hero-with-image': coverImage }]}>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import SiteLayout from '../../layouts/SiteLayout.astro';
3
3
  import BlogHome from '../../components/BlogHome.astro';
4
- import { getCategories } from '../../lib/posts.js';
4
+ import { getCategories, getPosts } from '../../lib/posts.js';
5
5
  import site from '../../site.js';
6
6
 
7
7
  export function getStaticPaths() {
@@ -9,8 +9,10 @@ export function getStaticPaths() {
9
9
  }
10
10
 
11
11
  const { category } = Astro.props;
12
+ const firstPost = getPosts(site.language, site.timeZone).find(post => post.category === category);
13
+ const priorityImage = firstPost && (firstPost.ogImage ?? firstPost.image ?? site.seo?.ogImage);
12
14
  ---
13
15
 
14
- <SiteLayout site={site} page={{ title: category }}>
16
+ <SiteLayout site={site} page={{ title: category }} priorityImage={priorityImage}>
15
17
  <BlogHome site={site} category={category} />
16
18
  </SiteLayout>
@@ -2,9 +2,13 @@
2
2
  import SiteLayout from '../layouts/SiteLayout.astro';
3
3
  import BlogHome from '../components/BlogHome.astro';
4
4
  import site from '../site.js';
5
+ import { getPosts } from '../lib/posts.js';
5
6
  const title = site.home.title ?? `${site.name} Blog`;
7
+ const posts = getPosts(site.language, site.timeZone);
8
+ const firstPost = posts[0];
9
+ const priorityImage = firstPost && (firstPost.ogImage ?? firstPost.image ?? site.seo?.ogImage);
6
10
  ---
7
11
 
8
- <SiteLayout site={site} page={{ title }}>
12
+ <SiteLayout site={site} page={{ title }} posts={posts} priorityImage={priorityImage}>
9
13
  <BlogHome site={site} />
10
14
  </SiteLayout>
@@ -1,11 +1,11 @@
1
- .post-search-trigger { position: relative; flex: 0 1 240px; min-width: 180px; margin-left: auto; }
1
+ .post-search-trigger { position: relative; flex: 0 0 240px; margin-left: auto; }
2
2
  .post-search-trigger svg { position: absolute; left: 13px; top: 50%; width: 16px; height: 16px; transform: translateY(-50%); color: var(--site-text-muted); pointer-events: none; }
3
3
  .post-search-trigger button { width: 100%; height: 36px; padding: 0 14px 0 38px; border: 0; border-radius: 999px; background: var(--site-surface-hover); color: var(--site-text-secondary); font: 400 var(--text-label)/1.5 var(--font-family); text-align: left; cursor: pointer; }
4
4
  .post-search-trigger button:hover { background: var(--site-surface-active); color: var(--site-foreground); }
5
5
  .post-search-header input::placeholder { color: var(--site-text-secondary); opacity: 1; }
6
6
  .post-search-trigger button:focus-visible { outline: 1px solid var(--site-border-strong); outline-offset: 3px; border-radius: 999px; background: var(--site-surface-active); }
7
7
  html:has(.post-search-dialog[open]) { overflow: hidden; }
8
- .post-search-dialog { position: fixed; inset: 0; width: min(620px, calc(100vw - 32px)); max-width: none; max-height: min(440px, calc(100dvh - 48px)); margin: auto; padding: 0; overflow: hidden; border: 1px solid var(--site-border); border-radius: 14px; background: var(--site-background); color: var(--site-foreground); box-shadow: 0 20px 80px #0003; font: 400 var(--text-label)/1.5 var(--font-family); }
8
+ .post-search-dialog { position: fixed; inset: 0; width: min(640px, calc(100vw - 32px)); max-width: none; max-height: min(440px, calc(100dvh - 48px)); margin: auto; padding: 0; overflow: hidden; border: 1px solid var(--site-border); border-radius: 14px; background: var(--site-background); color: var(--site-foreground); box-shadow: 0 20px 80px #0003; font: 400 var(--text-label)/1.5 var(--font-family); }
9
9
  .post-search-dialog[open] { display: flex; flex-direction: column; }
10
10
  .post-search-dialog::backdrop { background: color-mix(in srgb, var(--site-background) 65%, transparent); backdrop-filter: blur(3px); }
11
11
  .post-search-header { display: flex; flex-shrink: 0; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--site-surface, #fafafa); }
@@ -26,8 +26,18 @@ html:has(.post-search-dialog[open]) { overflow: hidden; }
26
26
  .post-search-excerpt { font-size: var(--text-meta); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--site-text-secondary); }
27
27
  .post-search-empty { margin: 0; padding: 20px 16px; color: var(--site-text-secondary); text-align: center; }
28
28
  .post-search-results:empty { flex: 0; }
29
+ @container (max-width: 768px) {
30
+ .blog-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 44px; gap: 0; }
31
+ .category-selector { grid-area: 1 / 1 / 2 / -1; padding-inline-end: 56px; gap: 20px; }
32
+ .post-count { grid-area: 1 / 1; }
33
+ .post-search-trigger { grid-area: 1 / 2; z-index: 1; width: 44px; background: var(--site-background); }
34
+ .post-search-trigger::before { content: ''; position: absolute; inset: 0 100% 0 -16px; background: linear-gradient(to right, transparent, var(--site-background)); pointer-events: none; }
35
+ .post-search-trigger svg { left: auto; right: 0; width: 18px; height: 18px; }
36
+ .post-search-trigger button { height: 44px; padding: 0; background: transparent; }
37
+ .post-search-trigger button:is(:hover, :focus-visible) { background: transparent; }
38
+ .post-search-trigger button span { display: none; }
39
+ }
29
40
  @media (max-width: 640px) {
30
- .post-search-trigger { flex-basis: 100%; min-width: 0; }
31
41
  .post-search-dialog { width: calc(100vw - 24px); max-height: min(440px, calc(100dvh - 24px)); }
32
42
  .post-search-header { padding: 10px 12px; }
33
43
  .post-search-result { gap: 10px; }
@@ -72,18 +72,19 @@ html:has(.site-header[data-header-position='fixed']) { scroll-padding-top: 80px;
72
72
  @media (prefers-reduced-motion: reduce) {
73
73
  .mobile-menu-toggle svg { transition: none; }
74
74
  }
75
- .blog-home { padding-block: 72px 100px; }
76
- .blog-heading { margin-bottom: 18px; }
77
- .blog-home:not(:has(.blog-toolbar > :not([hidden], script, dialog))) > .blog-heading { margin-bottom: 64px; }
75
+ .blog-home { container-type: inline-size; padding-block: 72px 100px; }
76
+ .blog-heading { margin-bottom: 40px; }
78
77
  .blog-heading h1 { margin-bottom: 0; }
79
78
  .article-category { margin: 0 0 20px; }
80
79
  .article-category a, .post-card-category { display: inline-flex; align-items: center; justify-content: center; padding: 4px 15px; border: 0; border-radius: 10px; background: var(--site-surface); color: var(--muted); font-size: var(--text-meta); font-weight: 500; line-height: 1.333333; text-decoration: none; }
81
80
  h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-spacing: -.04em; margin: 0 0 22px; }
82
81
  .description { color: var(--text-secondary); font-size: var(--text-base); line-height: 1.65; max-width: 660px; margin: 0; }
83
- .blog-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px 32px; margin-bottom: 40px; }
84
- .blog-toolbar:not(:has(> :not([hidden], script, dialog))) { display: none; }
85
- .category-selector { display: flex; flex-wrap: wrap; gap: 8px 24px; }
86
- .category-selector a { padding-block: 8px; color: var(--muted); font-size: var(--text-small); line-height: 1.5; text-decoration: none; transition: color .15s; }
82
+ .blog-toolbar { display: flex; align-items: center; gap: 32px; margin-bottom: 40px; }
83
+ .category-selector { display: flex; flex: 1; min-width: 0; gap: 24px; overflow-x: auto; scrollbar-width: none; }
84
+ .category-selector::-webkit-scrollbar { display: none; }
85
+ .category-selector a { flex-shrink: 0; white-space: nowrap; padding-block: 8px; color: var(--muted); font-size: var(--text-small); line-height: 1.5; text-decoration: none; transition: color .15s; }
86
+ .category-selector a:focus-visible { outline-offset: -2px; }
87
+ .post-count { margin: 0; padding-block: 8px; color: var(--muted); font-size: var(--text-small); white-space: nowrap; }
87
88
  .category-selector a:hover { color: var(--site-foreground); }
88
89
  .category-selector a[aria-current='page'] { color: var(--site-foreground); font-weight: 500; }
89
90
  .post-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 64px 48px; }
@@ -139,7 +140,7 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
139
140
  .archive > h2 { font-size: var(--text-h3); font-weight: 500; letter-spacing: -.025em; margin: 0 0 20px; }
140
141
  .archive-list { display: grid; gap: 4px; }
141
142
  .archive-list .post-card { margin-inline: -12px; padding-inline: 12px; border-radius: 6px; }
142
- .archive-list .post-card:is(:hover, :focus-within) { background: var(--site-surface-hover); }
143
+ .archive-list .post-card:is(:hover, :focus-within) { --site-text-muted: var(--site-text-on-surface); background: var(--site-surface-hover); }
143
144
  .archive-list .post-card-layout { padding-block: 18px; }
144
145
  .archive-list .post-card-cover, .archive-list .post-card-description { display: none; }
145
146
  .archive-list .post-card-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .85fr); align-items: center; gap: 16px 32px; }
@@ -235,8 +236,7 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
235
236
  .site-footer a:hover { color: var(--site-foreground); }
236
237
  .footer-bottom { display: flex; align-items: center; flex-wrap: wrap; gap: 16px 24px; margin-top: 64px; color: var(--muted); font-size: var(--text-small); }
237
238
  .footer-bottom a { text-decoration: none; }
238
- .footer-credit { margin-left: auto; opacity: .65; }
239
- .footer-credit:is(:hover, :focus-visible) { opacity: 1; }
239
+ .footer-credit { margin-left: auto; color: var(--site-text-muted); }
240
240
  .footer-socials { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
241
241
  .footer-socials a { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; min-height: 28px; }
242
242
  .footer-socials svg { width: 18px; height: 18px; }
@@ -271,9 +271,7 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
271
271
  .mobile-menu-panel a { flex: none; padding: 12px 0; font-size: var(--text-base); line-height: 1.4; overflow-wrap: anywhere; }
272
272
  @media (prefers-reduced-motion: no-preference) { .mobile-navigation[open] .mobile-menu-panel { animation: mobile-menu-enter 180ms ease-out; } }
273
273
  .blog-home { padding-block: 46px 64px; }
274
- .blog-home:not(:has(.blog-toolbar > :not([hidden], script, dialog))) > .blog-heading { margin-bottom: 40px; }
275
- .blog-toolbar { margin-bottom: 36px; }
276
- .category-selector { gap: 4px 20px; }
274
+ .blog-heading, .blog-toolbar { margin-bottom: 36px; }
277
275
  .post-list { grid-template-columns: 1fr; gap: 48px; }
278
276
  .related-posts { margin-top: 56px; padding-top: 32px; }
279
277
  [data-home-layout='rows'] .post-list .post-card-layout { display: flex; align-items: stretch; gap: 20px; }