@lettercamp/cli 0.1.11 → 0.1.12
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 +2 -1
- package/dist/content-components.js +25 -3
- package/dist/dev.js +2 -1
- package/dist/search-content.js +24 -0
- package/package.json +1 -1
- package/renderer/src/components/BlogHome.astro +8 -4
- package/renderer/src/components/Footer.astro +1 -1
- package/renderer/src/components/Header.astro +1 -1
- package/renderer/src/components/PostSearch.astro +30 -0
- package/renderer/src/components/RelatedPosts.astro +11 -0
- package/renderer/src/layouts/SiteLayout.astro +5 -2
- package/renderer/src/lib/branding.js +2 -0
- package/renderer/src/lib/faq.ts +25 -0
- package/renderer/src/lib/post-search.ts +194 -0
- package/renderer/src/lib/related-posts.ts +50 -0
- package/renderer/src/pages/[...slug].astro +11 -3
- package/renderer/src/styles/post-search.css +37 -0
- package/renderer/src/styles/site.css +23 -15
package/dist/build.js
CHANGED
|
@@ -8,6 +8,7 @@ import mdx from '@astrojs/mdx';
|
|
|
8
8
|
import { unified } from '@astrojs/markdown-remark';
|
|
9
9
|
import { prepareWorkspace } from './workspace.js';
|
|
10
10
|
import { readingTime } from './reading-time.js';
|
|
11
|
+
import { searchContent } from './search-content.js';
|
|
11
12
|
import { mediaDimensions } from './media-dimensions.js';
|
|
12
13
|
import { reactViteConfig } from './react.js';
|
|
13
14
|
export async function buildSite(directory, options) {
|
|
@@ -39,7 +40,7 @@ export async function buildSite(directory, options) {
|
|
|
39
40
|
publicDir: path.join(workspace, '.lettercamp-no-public'), outDir, output: 'static',
|
|
40
41
|
integrations: [mdx(), react()],
|
|
41
42
|
vite: reactViteConfig,
|
|
42
|
-
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
|
|
43
|
+
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, searchContent, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
|
|
43
44
|
build: { format: 'directory', ...(options.assetsPrefix ? { assetsPrefix: options.assetsPrefix } : {}) },
|
|
44
45
|
});
|
|
45
46
|
return outDir;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readCodeSettings, writeCodeSettings } from "./code-block.js";
|
|
2
2
|
import { textStyleString } from "./text-styles.js";
|
|
3
|
-
export const contentComponentNames = ["KeyTakeaways", "Image", "Video", "Audio", "CodeBlock", "Table", "TableRow", "TableCell"];
|
|
3
|
+
export const contentComponentNames = ["KeyTakeaways", "FAQ", "FAQItem", "Image", "Video", "Audio", "CodeBlock", "Table", "TableRow", "TableCell"];
|
|
4
4
|
export const isContentElement = (node) => node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement";
|
|
5
5
|
export const isContentComponent = (node) => isContentElement(node) && contentComponentNames.includes(node.name);
|
|
6
6
|
// Only literal values are interpreted. Expressions, spreads, and user code stay opaque.
|
|
@@ -66,6 +66,24 @@ export function expandContentComponent(node) {
|
|
|
66
66
|
return null;
|
|
67
67
|
const inline = node.type === "mdxJsxTextElement";
|
|
68
68
|
switch (node.name) {
|
|
69
|
+
case "FAQ": {
|
|
70
|
+
if (inline || !allowed(props, ["width"]))
|
|
71
|
+
return null;
|
|
72
|
+
const items = meaningful(node.children.flatMap(child => child.type === "paragraph" ? child.children : [child]));
|
|
73
|
+
const details = [];
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
if (!isContentElement(item) || item.name !== "FAQItem")
|
|
76
|
+
return null;
|
|
77
|
+
const itemProps = componentProps(item);
|
|
78
|
+
if (!itemProps || !allowed(itemProps, ["question"]) || typeof itemProps.question !== "string")
|
|
79
|
+
return null;
|
|
80
|
+
details.push(html("details", {}, [
|
|
81
|
+
html("summary", {}, [{ type: "paragraph", children: [{ type: "text", value: itemProps.question }] }]),
|
|
82
|
+
html("div", { "data-faq-answer": "true" }, item.children),
|
|
83
|
+
]));
|
|
84
|
+
}
|
|
85
|
+
return html("div", { "data-lettercamp-faq": "true", "data-block-width": widthOf(props.width) }, details);
|
|
86
|
+
}
|
|
69
87
|
case "KeyTakeaways":
|
|
70
88
|
return allowed(props, ["width"]) && !inline ? html("div", { "data-lettercamp-key-takeaways": "true", "data-block-width": widthOf(props.width) }, node.children) : null;
|
|
71
89
|
case "Image":
|
|
@@ -195,6 +213,10 @@ export function importedComponentNames(tree) {
|
|
|
195
213
|
// Generic mdast nodes with hName work in both Markdown and Astro's MDX compiler.
|
|
196
214
|
export function renderContentComponents(tree) {
|
|
197
215
|
const imported = importedComponentNames(tree);
|
|
216
|
+
// Markdown needs a paragraph context to escape question text when exporting
|
|
217
|
+
// legacy HTML. A native summary only accepts phrasing content on the page.
|
|
218
|
+
const childrenOf = (node) => isContentElement(node) && node.name === "summary"
|
|
219
|
+
? node.children.flatMap(child => child.type === "paragraph" ? child.children : [child]) : node.children;
|
|
198
220
|
const visit = (node) => {
|
|
199
221
|
if (isContentComponent(node) && !imported.has(node.name)) {
|
|
200
222
|
const expanded = expandContentComponent(node);
|
|
@@ -203,7 +225,7 @@ export function renderContentComponents(tree) {
|
|
|
203
225
|
return lower(expanded);
|
|
204
226
|
}
|
|
205
227
|
if ("children" in node)
|
|
206
|
-
node.children = node.
|
|
228
|
+
node.children = childrenOf(node).map(visit);
|
|
207
229
|
return node;
|
|
208
230
|
};
|
|
209
231
|
const lower = (node) => {
|
|
@@ -213,7 +235,7 @@ export function renderContentComponents(tree) {
|
|
|
213
235
|
// would silently remove playback controls from published MDX pages.
|
|
214
236
|
if (Object.hasOwn(properties, "controls"))
|
|
215
237
|
properties.controls = true;
|
|
216
|
-
const children = node.
|
|
238
|
+
const children = childrenOf(node).map(child => isContentElement(child) && child.name && /^[a-z]/.test(child.name) ? lower(child) : visit(child));
|
|
217
239
|
return { type: "lettercampElement", data: { hName: node.name, hProperties: properties }, children };
|
|
218
240
|
}
|
|
219
241
|
return visit(node);
|
package/dist/dev.js
CHANGED
|
@@ -10,6 +10,7 @@ import { unified } from '@astrojs/markdown-remark';
|
|
|
10
10
|
import { prepareWorkspace } from './workspace.js';
|
|
11
11
|
import { selectDefaultPort } from './ports.js';
|
|
12
12
|
import { readingTime } from './reading-time.js';
|
|
13
|
+
import { searchContent } from './search-content.js';
|
|
13
14
|
import { mediaDimensions } from './media-dimensions.js';
|
|
14
15
|
import { reactIntegrationDirectory, reactViteConfig } from './react.js';
|
|
15
16
|
import { writeCustomLayout } from './custom-layout.js';
|
|
@@ -59,7 +60,7 @@ export async function startDev(directory, options) {
|
|
|
59
60
|
addMiddleware({ entrypoint: options.middleware, order: 'post' });
|
|
60
61
|
} },
|
|
61
62
|
}] : [])],
|
|
62
|
-
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root: contentRoot }]], rehypePlugins: [[mediaDimensions, { root: contentRoot }]] }) },
|
|
63
|
+
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, searchContent, codeBlocks, componentBlocks, [mediaDimensions, { root: contentRoot }]], rehypePlugins: [[mediaDimensions, { root: contentRoot }]] }) },
|
|
63
64
|
devToolbar: { enabled: false },
|
|
64
65
|
server: { port, host: options.host, open: options.open },
|
|
65
66
|
vite: {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { fromHtml } from 'hast-util-from-html';
|
|
2
|
+
// Extract authored text during compilation, never imports, JSX props, or
|
|
3
|
+
// expressions. Dynamic component output is not part of the static index.
|
|
4
|
+
export function searchContent() {
|
|
5
|
+
return (tree, file) => {
|
|
6
|
+
const parts = [];
|
|
7
|
+
function visit(node) {
|
|
8
|
+
if (['script', 'style', 'template'].includes((node.tagName ?? node.name ?? '').toLowerCase()))
|
|
9
|
+
return;
|
|
10
|
+
if (['text', 'inlineCode', 'code'].includes(node.type))
|
|
11
|
+
parts.push(node.value ?? '');
|
|
12
|
+
if (node.type === 'image')
|
|
13
|
+
parts.push(node.alt ?? '');
|
|
14
|
+
if (node.type === 'html')
|
|
15
|
+
visit(fromHtml(node.value ?? '', { fragment: true }));
|
|
16
|
+
for (const child of node.children ?? [])
|
|
17
|
+
visit(child);
|
|
18
|
+
}
|
|
19
|
+
visit(tree);
|
|
20
|
+
const astro = file.data.astro;
|
|
21
|
+
if (astro?.frontmatter)
|
|
22
|
+
astro.frontmatter.searchText = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
23
|
+
};
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { sitePath } from '../lib/urls.js';
|
|
3
3
|
import PostCard from './PostCard.astro';
|
|
4
|
+
import PostSearch from './PostSearch.astro';
|
|
4
5
|
import { getPosts, getCategories } from '../lib/posts.js';
|
|
5
6
|
import { readHomeWidthPx } from '../lib/home-layout';
|
|
6
7
|
const { site, category = '' } = Astro.props;
|
|
@@ -16,10 +17,13 @@ const featuredLimit = 9;
|
|
|
16
17
|
<h1>{category || (site.home.title ?? `${site.name} Blog`)}</h1>
|
|
17
18
|
{site.description && <p class="description" style="margin-top: 12px">{site.description}</p>}
|
|
18
19
|
</header>
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
<div class="blog-toolbar">
|
|
21
|
+
{hasCategories && <nav class="category-selector" aria-label="Filter posts by category" hidden={site.home?.showCategories === false}>
|
|
22
|
+
<a href={sitePath("/")} data-category="" aria-current={!category ? "page" : undefined}>All</a>
|
|
23
|
+
{categories.map(item => <a href={item.href} data-category={item.name} aria-current={category === item.name ? "page" : undefined}>{item.name}</a>)}
|
|
24
|
+
</nav>}
|
|
25
|
+
<PostSearch posts={allPosts} />
|
|
26
|
+
</div>
|
|
23
27
|
<p class="sr-only" data-results-count role="status" aria-live="polite">{posts.length} articles</p>
|
|
24
28
|
<section aria-label="Recent articles">
|
|
25
29
|
<h2 class="sr-only">Recent articles</h2>
|
|
@@ -15,7 +15,7 @@ const socials = (site.footer?.socials ?? []).filter(({ href }) => {
|
|
|
15
15
|
<div class="footer-main">
|
|
16
16
|
<div class="footer-brand">
|
|
17
17
|
<a class="brand" href={sitePath("/")} aria-label={`${site.name} home`}>
|
|
18
|
-
<img src={logo} width="24" height="24" alt="" />
|
|
18
|
+
{logo ? <img src={logo} width="24" height="24" alt="" /> : <span>{site.name}</span>}
|
|
19
19
|
</a>
|
|
20
20
|
</div>
|
|
21
21
|
{columns.length > 0 && <div class="footer-columns">
|
|
@@ -14,7 +14,7 @@ const logo = site.logo;
|
|
|
14
14
|
<template data-mobile-menu-icons><MobileMenuIcons /></template>
|
|
15
15
|
<div class="site-header-inner">
|
|
16
16
|
<a class="brand" href={sitePath("/")}>
|
|
17
|
-
<img src={logo} width="30" height="30" alt="" />
|
|
17
|
+
{logo && <img src={logo} width="30" height="30" alt="" />}
|
|
18
18
|
<span>{site.name}</span>
|
|
19
19
|
</a>
|
|
20
20
|
{(navLinks.length > 0 || primary) && <div class="header-actions">
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../styles/post-search.css';
|
|
3
|
+
import { postSearchScript } from '../lib/post-search';
|
|
4
|
+
const { posts } = Astro.props;
|
|
5
|
+
// Only public post fields enter the browser. Escape HTML delimiters even in an
|
|
6
|
+
// inert JSON script: article text may contain a literal closing script tag.
|
|
7
|
+
const index = JSON.stringify(posts.map(post => ({
|
|
8
|
+
title: String(post.title), href: post.href,
|
|
9
|
+
summary: typeof post.description === 'string' ? post.description : '',
|
|
10
|
+
text: typeof post.searchText === 'string' ? post.searchText : '',
|
|
11
|
+
category: post.category, date: post.date.label,
|
|
12
|
+
}))).replace(/</g, '\\u003c');
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<div class="post-search-trigger" hidden>
|
|
16
|
+
<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>
|
|
17
|
+
<input type="search" readonly placeholder="Search articles…" aria-label="Search articles" aria-haspopup="dialog" aria-controls="post-search-dialog" />
|
|
18
|
+
</div>
|
|
19
|
+
<dialog id="post-search-dialog" class="post-search-dialog" aria-label="Search articles">
|
|
20
|
+
<div class="post-search-header">
|
|
21
|
+
<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>
|
|
22
|
+
<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 />
|
|
23
|
+
<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>
|
|
24
|
+
</div>
|
|
25
|
+
<p class="post-search-status" role="status" aria-live="polite" aria-atomic="true"></p>
|
|
26
|
+
<ul id="post-search-results" class="post-search-results" aria-label="Matching articles"></ul>
|
|
27
|
+
<p class="post-search-empty" hidden>No articles found. Try a different word or title.</p>
|
|
28
|
+
</dialog>
|
|
29
|
+
<script is:inline type="application/json" data-post-search-index set:html={index} />
|
|
30
|
+
<script is:inline set:html={postSearchScript} />
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import PostCard from './PostCard.astro';
|
|
3
|
+
const { posts, site } = Astro.props;
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
{posts.length > 0 && <section class="related-posts" aria-labelledby="related-posts-heading" hidden={site.post?.showRelatedPosts === false}>
|
|
7
|
+
<h2 id="related-posts-heading">Related</h2>
|
|
8
|
+
<div class="post-list">
|
|
9
|
+
{posts.map(post => <PostCard post={post} site={site} />)}
|
|
10
|
+
</div>
|
|
11
|
+
</section>}
|
|
@@ -14,6 +14,7 @@ import { mobileNavigationScript } from '../lib/mobile-navigation';
|
|
|
14
14
|
import { blockWidthStyles } from '../lib/block-widths';
|
|
15
15
|
import { tableStyles } from '../lib/table-styles';
|
|
16
16
|
import { keyTakeawaysStyles } from '../lib/key-takeaways';
|
|
17
|
+
import { faqStyles } from '../lib/faq';
|
|
17
18
|
import { siteColors } from '../lib/site-colors';
|
|
18
19
|
import { imageWidthStyles } from '../lib/image-widths';
|
|
19
20
|
import { googleAnalyticsScript } from '../lib/analytics';
|
|
@@ -25,14 +26,15 @@ const meta = pageMetadata(site, page, Astro.url, article, basePath);
|
|
|
25
26
|
const typography = site.design?.typography ?? {};
|
|
26
27
|
const fonts = fontResources(typography, article ? 700 : 500);
|
|
27
28
|
const fontSize = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
28
|
-
const
|
|
29
|
+
const headingBaseSize = fontSize(typography.headingBaseSizePx, 18);
|
|
30
|
+
const bodyBaseSize = fontSize(typography.bodyBaseSizePx, 16);
|
|
29
31
|
const bodyLineHeight = fontSize(typography.bodyLineHeight, 1.8);
|
|
30
32
|
const colors = siteColors(site.design);
|
|
31
33
|
const analytics = googleAnalyticsScript(site.analytics?.googleAnalyticsId);
|
|
32
34
|
---
|
|
33
35
|
|
|
34
36
|
<!doctype html>
|
|
35
|
-
<html data-base-path={basePath} data-site-accent={colors['--site-accent'] !== 'inherit' ? '' : undefined} lang={meta.language} style={{ ...colors, '--image-radius': readImageRadius(site.design?.imageRadiusPercent) / 100, '--font-family': fonts.bodyFamily, '--heading-font': fonts.headingFamily, '--type-scale':
|
|
37
|
+
<html data-base-path={basePath} data-site-accent={colors['--site-accent'] !== 'inherit' ? '' : undefined} lang={meta.language} style={{ ...colors, '--image-radius': readImageRadius(site.design?.imageRadiusPercent) / 100, '--font-family': fonts.bodyFamily, '--heading-font': fonts.headingFamily, '--heading-type-scale': headingBaseSize / 18, '--body-type-scale': bodyBaseSize / 16, '--body-line-height': bodyLineHeight }}>
|
|
36
38
|
<head>
|
|
37
39
|
<meta charset="utf-8" />
|
|
38
40
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -48,6 +50,7 @@ const analytics = googleAnalyticsScript(site.analytics?.googleAnalyticsId);
|
|
|
48
50
|
<style is:inline set:html={codeBlockStyles} />
|
|
49
51
|
<style is:inline set:html={tableStyles} />
|
|
50
52
|
<style is:inline set:html={keyTakeawaysStyles} />
|
|
53
|
+
<style is:inline set:html={faqStyles} />
|
|
51
54
|
<title>{meta.documentTitle}</title>
|
|
52
55
|
<meta name="description" content={meta.description} />
|
|
53
56
|
{meta.keywords && <meta name="keywords" content={meta.keywords} />}
|
|
@@ -7,6 +7,8 @@ export const defaultSiteBranding = {
|
|
|
7
7
|
export function withDefaultBranding(source) {
|
|
8
8
|
const branding = Object.fromEntries(Object.entries(defaultSiteBranding).map(([key, fallback]) =>
|
|
9
9
|
[key, typeof source[key] === 'string' && source[key].trim() ? source[key] : fallback]));
|
|
10
|
+
// An explicit empty logo disables the image while omitted logos keep the default.
|
|
11
|
+
if (typeof source.logo === 'string') branding.logo = source.logo.trim();
|
|
10
12
|
return {
|
|
11
13
|
...source,
|
|
12
14
|
...branding,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/faq.ts.
|
|
2
|
+
// Shared by the editor preview and published posts. Native disclosures work
|
|
3
|
+
// without hydration and let readers keep several answers open at once.
|
|
4
|
+
export const faqStyles = `
|
|
5
|
+
[data-lettercamp-faq] { margin-block: 24px; color: inherit; overflow-wrap: anywhere; }
|
|
6
|
+
[data-lettercamp-faq] > details { border-bottom: 1px solid var(--site-border, #e5e5e5); }
|
|
7
|
+
[data-lettercamp-faq] > details:first-child { border-top: 1px solid var(--site-border, #e5e5e5); }
|
|
8
|
+
[data-lettercamp-faq] > details > summary {
|
|
9
|
+
display: flex; align-items: center; justify-content: space-between; gap: 24px;
|
|
10
|
+
padding: 18px 0; cursor: pointer; list-style: none; font-weight: 600; line-height: 1.5;
|
|
11
|
+
}
|
|
12
|
+
[data-lettercamp-faq] > details > summary::-webkit-details-marker { display: none; }
|
|
13
|
+
[data-lettercamp-faq] > details > summary::after {
|
|
14
|
+
content: ''; width: 7px; height: 7px; flex: 0 0 7px; margin: 0 4px 4px;
|
|
15
|
+
border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
|
|
16
|
+
transform: rotate(45deg); transition: transform 150ms;
|
|
17
|
+
}
|
|
18
|
+
[data-lettercamp-faq] > details[open] > summary::after { transform: translateY(4px) rotate(225deg); }
|
|
19
|
+
[data-lettercamp-faq] > details > summary:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; border-radius: 2px; }
|
|
20
|
+
[data-lettercamp-faq] > details > [data-faq-answer] { padding: 0 28px 20px 0; }
|
|
21
|
+
[data-lettercamp-faq] > details > [data-faq-answer] > :first-child { margin-top: 0; }
|
|
22
|
+
[data-lettercamp-faq] > details > [data-faq-answer] > :last-child { margin-bottom: 0; }
|
|
23
|
+
@media (prefers-reduced-motion: reduce) { [data-lettercamp-faq] > details > summary::after { transition: none; } }
|
|
24
|
+
@media print { [data-lettercamp-faq] > details::details-content { content-visibility: visible; } }
|
|
25
|
+
`;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export const postSearchScript = String.raw`(() => {
|
|
2
|
+
const trigger = document.querySelector('.post-search-trigger');
|
|
3
|
+
const dialog = document.querySelector('#post-search-dialog');
|
|
4
|
+
const data = document.querySelector('[data-post-search-index]');
|
|
5
|
+
if (!trigger || !dialog || !data || typeof dialog.showModal !== 'function') return;
|
|
6
|
+
const opener = trigger.querySelector('input');
|
|
7
|
+
const input = dialog.querySelector('input');
|
|
8
|
+
const results = dialog.querySelector('.post-search-results');
|
|
9
|
+
const status = dialog.querySelector('.post-search-status');
|
|
10
|
+
const empty = dialog.querySelector('.post-search-empty');
|
|
11
|
+
const limit = 20;
|
|
12
|
+
let posts;
|
|
13
|
+
const normalized = [];
|
|
14
|
+
let generation = 0;
|
|
15
|
+
let timer;
|
|
16
|
+
let restoringFocus = false;
|
|
17
|
+
const normalize = text => text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
18
|
+
const element = (tag, className, text) => {
|
|
19
|
+
const node = document.createElement(tag);
|
|
20
|
+
node.className = className;
|
|
21
|
+
node.textContent = text;
|
|
22
|
+
return node;
|
|
23
|
+
};
|
|
24
|
+
function highlighted(className, text, terms) {
|
|
25
|
+
const node = element('span', className, '');
|
|
26
|
+
if (!terms.length) { node.textContent = text; return node; }
|
|
27
|
+
// Map folded characters back to authored text, including accents and
|
|
28
|
+
// ligatures. Only the displayed titles/excerpts need this small offset map.
|
|
29
|
+
let folded = '';
|
|
30
|
+
let offset = 0;
|
|
31
|
+
const starts = [], ends = [];
|
|
32
|
+
for (const character of text) {
|
|
33
|
+
const end = offset + character.length;
|
|
34
|
+
const value = character.normalize('NFKD').replace(/\p{M}/gu, '').replace(/\s/g, ' ');
|
|
35
|
+
if (!value && ends.length) ends[ends.length - 1] = end;
|
|
36
|
+
for (const unit of value) {
|
|
37
|
+
if (unit === ' ' && folded.endsWith(' ')) { ends[ends.length - 1] = end; continue; }
|
|
38
|
+
folded += unit;
|
|
39
|
+
for (let i = 0; i < unit.length; i++) { starts.push(offset); ends.push(end); }
|
|
40
|
+
}
|
|
41
|
+
offset = end;
|
|
42
|
+
}
|
|
43
|
+
folded = folded.toLowerCase();
|
|
44
|
+
const ranges = [];
|
|
45
|
+
for (const term of new Set([normalize(input.value), ...terms])) {
|
|
46
|
+
let start = folded.indexOf(term);
|
|
47
|
+
while (start >= 0) {
|
|
48
|
+
ranges.push([starts[start], ends[start + term.length - 1]]);
|
|
49
|
+
start = folded.indexOf(term, start + term.length);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
53
|
+
const merged = [];
|
|
54
|
+
for (const range of ranges) {
|
|
55
|
+
const previous = merged.at(-1);
|
|
56
|
+
if (previous && range[0] <= previous[1]) previous[1] = Math.max(previous[1], range[1]);
|
|
57
|
+
else merged.push(range);
|
|
58
|
+
}
|
|
59
|
+
let cursor = 0;
|
|
60
|
+
for (const [start, end] of merged) {
|
|
61
|
+
node.append(document.createTextNode(text.slice(cursor, start)), element('mark', 'post-search-match', text.slice(start, end)));
|
|
62
|
+
cursor = end;
|
|
63
|
+
}
|
|
64
|
+
node.append(document.createTextNode(text.slice(cursor)));
|
|
65
|
+
return node;
|
|
66
|
+
}
|
|
67
|
+
const snippet = (post, terms, entry) => {
|
|
68
|
+
const text = post.text || post.summary;
|
|
69
|
+
const folded = post.text ? entry.text : entry.summary;
|
|
70
|
+
const match = terms.map(term => folded.indexOf(term)).filter(index => index >= 0);
|
|
71
|
+
const start = match.length ? Math.max(0, Math.min(...match) - 55) : 0;
|
|
72
|
+
// Normalization can change character offsets slightly; excerpts are just
|
|
73
|
+
// context, so keep the original authored text rather than folded text.
|
|
74
|
+
return (start ? '…' : '') + text.slice(start, start + 180) + (text.length > start + 180 ? '…' : '');
|
|
75
|
+
};
|
|
76
|
+
function render(matches, count, terms) {
|
|
77
|
+
const fragment = document.createDocumentFragment();
|
|
78
|
+
for (const { index } of matches) {
|
|
79
|
+
const post = posts[index];
|
|
80
|
+
const row = document.createElement('li');
|
|
81
|
+
const link = element('a', 'post-search-result', '');
|
|
82
|
+
link.href = post.href;
|
|
83
|
+
link.append(highlighted('post-search-title', post.title, terms));
|
|
84
|
+
const meta = [post.category, post.date].filter(Boolean).join(' · ');
|
|
85
|
+
if (meta) link.append(element('span', 'post-search-meta', meta));
|
|
86
|
+
const excerpt = terms.length ? snippet(post, terms, normalized[index]) : post.summary;
|
|
87
|
+
if (excerpt) link.append(highlighted('post-search-excerpt', excerpt, terms));
|
|
88
|
+
row.append(link);
|
|
89
|
+
fragment.append(row);
|
|
90
|
+
}
|
|
91
|
+
results.replaceChildren(fragment);
|
|
92
|
+
results.scrollTop = 0;
|
|
93
|
+
results.removeAttribute('aria-busy');
|
|
94
|
+
empty.hidden = count > 0;
|
|
95
|
+
empty.textContent = posts.length ? 'No articles found. Try a different word or title.' : 'No articles here yet.';
|
|
96
|
+
status.textContent = !terms.length ? 'Recent articles' : count > limit
|
|
97
|
+
? 'Showing ' + limit + ' of ' + count + ' matching articles. Keep typing to narrow your search.'
|
|
98
|
+
: count + (count === 1 ? ' article found' : ' articles found');
|
|
99
|
+
}
|
|
100
|
+
function search() {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
const current = ++generation;
|
|
103
|
+
const query = normalize(input.value);
|
|
104
|
+
const terms = [...new Set(query.split(' ').filter(Boolean))];
|
|
105
|
+
if (!terms.length) {
|
|
106
|
+
render(posts.slice(0, 8).map((_, index) => ({ index })), posts.length, terms);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Clear stale links immediately so Enter never opens an old query's result.
|
|
110
|
+
results.replaceChildren();
|
|
111
|
+
results.setAttribute('aria-busy', 'true');
|
|
112
|
+
empty.hidden = true;
|
|
113
|
+
status.textContent = 'Searching…';
|
|
114
|
+
let cursor = 0;
|
|
115
|
+
let count = 0;
|
|
116
|
+
const best = [];
|
|
117
|
+
function step() {
|
|
118
|
+
if (current !== generation || !dialog.open) return;
|
|
119
|
+
const deadline = performance.now() + 6;
|
|
120
|
+
while (cursor < posts.length) {
|
|
121
|
+
const index = cursor++;
|
|
122
|
+
const post = posts[index];
|
|
123
|
+
const entry = normalized[index] ??= {
|
|
124
|
+
title: normalize(post.title),
|
|
125
|
+
summary: normalize(post.summary), text: normalize(post.text),
|
|
126
|
+
};
|
|
127
|
+
if (terms.every(term => entry.title.includes(term) || entry.summary.includes(term) || entry.text.includes(term))) {
|
|
128
|
+
count++;
|
|
129
|
+
const score = (entry.title === query ? 1000 : entry.title.startsWith(query) ? 500 : entry.title.includes(query) ? 250 : 0)
|
|
130
|
+
+ terms.reduce((sum, term) => sum + (entry.title.includes(term) ? 10 : 0), 0);
|
|
131
|
+
const position = best.findIndex(item => score > item.score);
|
|
132
|
+
if (position >= 0) best.splice(position, 0, { index, score });
|
|
133
|
+
else if (best.length < limit) best.push({ index, score });
|
|
134
|
+
if (best.length > limit) best.pop();
|
|
135
|
+
}
|
|
136
|
+
// Yield between chunks for large publications. A newer query cancels
|
|
137
|
+
// this work; only the best 20 matches ever become DOM nodes.
|
|
138
|
+
if (performance.now() >= deadline) break;
|
|
139
|
+
}
|
|
140
|
+
if (cursor < posts.length) timer = setTimeout(step, 0);
|
|
141
|
+
else render(best, count, terms);
|
|
142
|
+
}
|
|
143
|
+
timer = setTimeout(step, 0);
|
|
144
|
+
}
|
|
145
|
+
function open() {
|
|
146
|
+
if (restoringFocus || dialog.open) return;
|
|
147
|
+
if (!posts) {
|
|
148
|
+
posts = JSON.parse(data.textContent);
|
|
149
|
+
data.textContent = '';
|
|
150
|
+
}
|
|
151
|
+
dialog.showModal();
|
|
152
|
+
input.focus({ preventScroll: true });
|
|
153
|
+
search();
|
|
154
|
+
}
|
|
155
|
+
function close() {
|
|
156
|
+
restoringFocus = true;
|
|
157
|
+
++generation;
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
dialog.close();
|
|
160
|
+
opener.focus({ preventScroll: true });
|
|
161
|
+
// Native dialog focus restoration must not immediately reopen the search.
|
|
162
|
+
queueMicrotask(() => { restoringFocus = false; });
|
|
163
|
+
}
|
|
164
|
+
opener.addEventListener('focus', open);
|
|
165
|
+
opener.addEventListener('click', open);
|
|
166
|
+
opener.addEventListener('keydown', event => {
|
|
167
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); }
|
|
168
|
+
});
|
|
169
|
+
input.addEventListener('input', event => { if (!event.isComposing) search(); });
|
|
170
|
+
input.addEventListener('compositionend', search);
|
|
171
|
+
dialog.querySelector('.post-search-close').addEventListener('click', close);
|
|
172
|
+
dialog.addEventListener('cancel', event => { event.preventDefault(); close(); });
|
|
173
|
+
let backdropDown = false;
|
|
174
|
+
dialog.addEventListener('pointerdown', event => { backdropDown = event.target === dialog; });
|
|
175
|
+
dialog.addEventListener('click', event => {
|
|
176
|
+
if (event.target === dialog && backdropDown) close();
|
|
177
|
+
});
|
|
178
|
+
dialog.addEventListener('keydown', event => {
|
|
179
|
+
if (event.isComposing) return;
|
|
180
|
+
const links = [...results.querySelectorAll('a')];
|
|
181
|
+
const index = links.indexOf(document.activeElement);
|
|
182
|
+
if (event.key === 'ArrowDown' && (event.target === input || index >= 0)) {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
(links[Math.min(index + 1, links.length - 1)] || input).focus();
|
|
185
|
+
} else if (event.key === 'ArrowUp' && (event.target === input || index >= 0)) {
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
(index === -1 ? links.at(-1) || input : links[index - 1] || input).focus();
|
|
188
|
+
} else if (event.key === 'Enter' && event.target === input) {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
links[0]?.click();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
trigger.hidden = false;
|
|
194
|
+
})();`;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/related-posts.ts.
|
|
2
|
+
type RelatedPost = { href: string; title?: unknown; description?: unknown; category?: unknown; draft?: unknown };
|
|
3
|
+
|
|
4
|
+
// Shared by the static renderer and editor preview. Keep this function self-contained
|
|
5
|
+
// so the preview bridge can embed it without shipping a separate client bundle.
|
|
6
|
+
export function createRelatedPostSelector<T extends RelatedPost>(posts: T[], language = "en") {
|
|
7
|
+
const stopWords: Record<string, string> = {
|
|
8
|
+
en: "a an and are as at be been but by for from has have how in is it its of on or our that the their this to was we were what when which who will with you your",
|
|
9
|
+
es: "a al con de del el en es esta este la las lo los o para por que se su sus un una y",
|
|
10
|
+
fr: "a au aux avec ce ces dans de des du elle en est et il la le les leur ou par pas pour que qui se son sur un une",
|
|
11
|
+
de: "am an auf aus bei das dem den der des die ein eine einer es für im in ist mit oder sich sind und von zu zum zur",
|
|
12
|
+
it: "a al alla che con da dal dei del della di e è gli i il in la le lo o per si su un una",
|
|
13
|
+
pt: "a ao as com da das de do dos e em é na nas no nos o os ou para por que se sua um uma",
|
|
14
|
+
};
|
|
15
|
+
const ignored = new Set((stopWords[language.toLowerCase().split("-")[0]] ?? "").split(" "));
|
|
16
|
+
const segmenter = new Intl.Segmenter(language, { granularity: "word" });
|
|
17
|
+
const text = {
|
|
18
|
+
normalize(value: unknown) {
|
|
19
|
+
return typeof value === "string" ? value.normalize("NFKC").toLocaleLowerCase(language).trim() : "";
|
|
20
|
+
},
|
|
21
|
+
prepare(post: T) {
|
|
22
|
+
const words = new Map<string, number>();
|
|
23
|
+
for (const [value, weight] of [[post.description, 1], [post.title, 3]] as const) {
|
|
24
|
+
for (const part of segmenter.segment(text.normalize(value))) {
|
|
25
|
+
if (part.isWordLike && !ignored.has(part.segment)) words.set(part.segment, weight);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { post, words, weight: [...words.values()].reduce((sum, weight) => sum + weight, 0), category: text.normalize(post.category) };
|
|
29
|
+
},
|
|
30
|
+
hash(value: string) {
|
|
31
|
+
let result = 2166136261;
|
|
32
|
+
for (let index = 0; index < value.length; index++) result = Math.imul(result ^ value.charCodeAt(index), 16777619);
|
|
33
|
+
return result >>> 0;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const candidates = [...new Map(posts.filter(post => post.draft !== true).map(post => [post.href, post])).values()].map(text.prepare);
|
|
37
|
+
const byHref = new Map(candidates.map(candidate => [candidate.post.href, candidate]));
|
|
38
|
+
return (current: T): T[] => {
|
|
39
|
+
const existing = byHref.get(current.href);
|
|
40
|
+
const source = existing?.post === current ? existing : text.prepare(current);
|
|
41
|
+
return candidates.filter(candidate => candidate.post.href !== current.href).map(candidate => {
|
|
42
|
+
let overlap = 0;
|
|
43
|
+
for (const [word, weight] of source.words) overlap += Math.min(weight, candidate.words.get(word) ?? 0);
|
|
44
|
+
const union = source.weight + candidate.weight - overlap;
|
|
45
|
+
const score = (union ? 75 * overlap / union : 0) + (source.category && source.category === candidate.category ? 25 : 0);
|
|
46
|
+
return { post: candidate.post, score, tie: text.hash(current.href + "\n" + candidate.post.href) };
|
|
47
|
+
}).sort((a, b) => b.score - a.score || a.tie - b.tie || (a.post.href < b.post.href ? -1 : a.post.href > b.post.href ? 1 : 0))
|
|
48
|
+
.slice(0, 3).map(candidate => candidate.post);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
---
|
|
2
2
|
import SiteLayout from '../layouts/SiteLayout.astro';
|
|
3
3
|
import AuthorByline from '../components/AuthorByline.astro';
|
|
4
|
-
import
|
|
4
|
+
import RelatedPosts from '../components/RelatedPosts.astro';
|
|
5
|
+
import { pageSlug, postDate, getCategories, getPosts } from '../lib/posts.js';
|
|
6
|
+
import { createRelatedPostSelector } from '../lib/related-posts';
|
|
7
|
+
import { sitePath } from '../lib/urls.js';
|
|
5
8
|
import site from '../site.js';
|
|
6
9
|
import { readContentWidthEm } from '../lib/post-layout';
|
|
7
10
|
import { pages } from '../content.js';
|
|
8
11
|
import { postShareButton } from '../lib/post-share';
|
|
9
12
|
|
|
10
13
|
export function getStaticPaths() {
|
|
14
|
+
const posts = getPosts(site.language, site.timeZone);
|
|
15
|
+
const selectRelated = createRelatedPostSelector(posts, site.language);
|
|
16
|
+
const byHref = new Map(posts.map(post => [post.href, post]));
|
|
11
17
|
return Object.entries(pages).map(([filename, page]) => {
|
|
12
18
|
const slug = pageSlug(filename);
|
|
13
|
-
|
|
19
|
+
const href = sitePath(`/${slug.split('/').map(encodeURIComponent).join('/')}/`);
|
|
20
|
+
return { params: { slug }, props: { page, relatedPosts: selectRelated(byHref.get(href)) } };
|
|
14
21
|
});
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
const { page } = Astro.props;
|
|
24
|
+
const { page, relatedPosts } = Astro.props;
|
|
18
25
|
const { Content, frontmatter = {} } = page;
|
|
19
26
|
const coverImage = frontmatter.image ?? frontmatter.ogImage;
|
|
20
27
|
const coverImageWidth = Number(frontmatter.coverImageWidth ?? (frontmatter.image ? frontmatter.imageWidth : undefined));
|
|
@@ -65,5 +72,6 @@ const coverWidth = /^[0-9]+$/.test(String(frontmatter.coverWidth)) && Number(fro
|
|
|
65
72
|
</aside>}
|
|
66
73
|
<div class="prose"><Content /></div>
|
|
67
74
|
</div>
|
|
75
|
+
<RelatedPosts posts={relatedPosts} site={site} />
|
|
68
76
|
</article>
|
|
69
77
|
</SiteLayout>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
.post-search-trigger { position: relative; flex: 0 1 240px; min-width: 180px; margin-left: auto; }
|
|
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
|
+
.post-search-trigger input { width: 100%; height: 36px; padding: 0 14px 0 38px; border: 1px solid var(--site-border-strong); border-radius: 999px; background: var(--site-background); color: var(--site-foreground); font: 400 var(--text-label)/1.5 var(--font-family); cursor: text; }
|
|
4
|
+
.post-search-trigger input::placeholder, .post-search-header input::placeholder { color: var(--site-text-secondary); opacity: 1; }
|
|
5
|
+
.post-search-trigger input:focus-visible { outline: 2px solid var(--site-foreground); outline-offset: 3px; }
|
|
6
|
+
html:has(.post-search-dialog[open]) { overflow: hidden; }
|
|
7
|
+
.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[open] { display: flex; flex-direction: column; }
|
|
9
|
+
.post-search-dialog::backdrop { background: color-mix(in srgb, var(--site-background) 65%, transparent); backdrop-filter: blur(3px); }
|
|
10
|
+
.post-search-header { display: flex; flex-shrink: 0; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--site-border); }
|
|
11
|
+
.post-search-header > svg { width: 20px; height: 20px; flex-shrink: 0; color: var(--site-text-secondary); }
|
|
12
|
+
.post-search-header input { min-width: 0; width: 100%; padding: 2px 0; border: 0; outline: none; background: transparent; color: inherit; font: 400 max(16px, var(--text-small))/1.5 var(--font-family); }
|
|
13
|
+
.post-search-header:focus-within { border-bottom-color: var(--site-border-strong); }
|
|
14
|
+
.post-search-close { display: grid; flex-shrink: 0; place-items: center; width: 28px; height: 28px; padding: 4px; border: 0; background: transparent; color: var(--site-text-secondary); }
|
|
15
|
+
.post-search-close:hover { color: var(--site-foreground); }
|
|
16
|
+
.post-search-close svg { width: 20px; height: 20px; }
|
|
17
|
+
.post-search-status { flex-shrink: 0; margin: 0; padding: 8px 16px 4px; color: var(--site-text-secondary); font-size: var(--text-meta); }
|
|
18
|
+
.post-search-results { flex: 0 1 auto; min-height: 0; margin: 0; padding: 0 6px 6px; overflow: auto; overscroll-behavior: contain; list-style: none; }
|
|
19
|
+
.post-search-result { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 8px; color: inherit; text-decoration: none; overflow-wrap: anywhere; }
|
|
20
|
+
.post-search-result:is(:hover, :focus-visible) { outline: none; background: var(--site-surface-active); border-radius: 8px; }
|
|
21
|
+
.post-search-title { font-size: var(--text-label); font-weight: 500; }
|
|
22
|
+
.post-search-meta { color: var(--site-text-secondary); font-size: var(--text-meta); }
|
|
23
|
+
.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); }
|
|
24
|
+
.post-search-empty { margin: 0; padding: 20px 16px; color: var(--site-text-secondary); text-align: center; }
|
|
25
|
+
.post-search-results:empty { flex: 0; }
|
|
26
|
+
@media (max-width: 640px) {
|
|
27
|
+
.post-search-trigger { flex-basis: 100%; min-width: 0; }
|
|
28
|
+
.post-search-dialog { width: calc(100vw - 24px); max-height: min(440px, calc(100dvh - 24px)); }
|
|
29
|
+
.post-search-header { padding: 10px 12px; }
|
|
30
|
+
}
|
|
31
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
32
|
+
.post-search-dialog[open] { animation: post-search-enter 140ms ease-out; }
|
|
33
|
+
@keyframes post-search-enter { from { opacity: 0; transform: translateY(6px) scale(.98); } to { opacity: 1; transform: none; } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.post-search-match { padding: 0 1px; border-radius: 3px; background: color-mix(in srgb, var(--site-foreground) 7%, transparent); color: var(--site-foreground); font-weight: 600; }
|
|
37
|
+
html[data-site-accent] .post-search-match { background: color-mix(in srgb, var(--site-accent) 25%, transparent); }
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
:root {
|
|
2
2
|
--font-family: 'Inter', Arial, Helvetica, sans-serif;
|
|
3
3
|
font-size: 100%;
|
|
4
|
-
--type-scale: 1;
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--text-
|
|
8
|
-
--text-
|
|
9
|
-
--text-
|
|
10
|
-
--text-
|
|
11
|
-
--text-
|
|
4
|
+
--heading-type-scale: 1;
|
|
5
|
+
--body-type-scale: 1;
|
|
6
|
+
--heading-base: calc(1.125rem * var(--heading-type-scale));
|
|
7
|
+
--text-base: calc(1rem * var(--body-type-scale));
|
|
8
|
+
--text-title: calc(clamp(2rem, 3.5vw, 2.625rem) * var(--heading-type-scale));
|
|
9
|
+
--text-card-title: calc(clamp(1.125rem, 1.4vw, 1.25rem) * var(--heading-type-scale));
|
|
10
|
+
--text-h2: calc(var(--heading-base) * 30 / 18);
|
|
11
|
+
--text-h3: calc(var(--heading-base) * 24 / 18);
|
|
12
|
+
--text-h4: calc(var(--heading-base) * 20 / 18);
|
|
13
|
+
--text-h5: var(--heading-base);
|
|
12
14
|
--text-summary: calc(var(--text-base) * .9375);
|
|
13
15
|
--text-small: calc(var(--text-base) * .875);
|
|
14
16
|
--text-label: calc(var(--text-base) * .8125);
|
|
@@ -72,17 +74,21 @@ html:has(.site-header[data-header-position='fixed']) { scroll-padding-top: 80px;
|
|
|
72
74
|
}
|
|
73
75
|
.blog-home { padding-block: 72px 100px; }
|
|
74
76
|
.blog-heading { margin-bottom: 18px; }
|
|
75
|
-
.blog-home:not(:has(>
|
|
77
|
+
.blog-home:not(:has(.blog-toolbar > :not([hidden], script, dialog))) > .blog-heading { margin-bottom: 64px; }
|
|
76
78
|
.blog-heading h1 { margin-bottom: 0; }
|
|
77
79
|
.article-category { margin: 0 0 20px; }
|
|
78
80
|
.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; }
|
|
79
81
|
h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-spacing: -.04em; margin: 0 0 22px; }
|
|
80
82
|
.description { color: var(--text-secondary); font-size: var(--text-base); line-height: 1.65; max-width: 660px; margin: 0; }
|
|
81
|
-
.
|
|
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; }
|
|
82
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; }
|
|
83
87
|
.category-selector a:hover { color: var(--site-foreground); }
|
|
84
88
|
.category-selector a[aria-current='page'] { color: var(--site-foreground); font-weight: 500; }
|
|
85
89
|
.post-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 64px 48px; }
|
|
90
|
+
.related-posts { margin-top: 80px; padding-top: 40px; }
|
|
91
|
+
.related-posts > h2 { margin: 0 0 32px; font-size: var(--text-h3); font-weight: 500; letter-spacing: -.025em; }
|
|
86
92
|
.post-card { position: relative; isolation: isolate; min-width: 0; }
|
|
87
93
|
.post-card-link { color: inherit; text-decoration: none; }
|
|
88
94
|
.post-card-link::after { content: ''; position: absolute; inset: 0; z-index: 1; }
|
|
@@ -138,7 +144,7 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
|
|
|
138
144
|
.archive-list .post-card-cover, .archive-list .post-card-description { display: none; }
|
|
139
145
|
.archive-list .post-card-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .85fr); align-items: center; gap: 16px 32px; }
|
|
140
146
|
.archive-list .post-card-details { display: none; }
|
|
141
|
-
.archive-list h3 { grid-area: 1 / 1; font-size: var(--
|
|
147
|
+
.archive-list h3 { grid-area: 1 / 1; font-size: calc(var(--heading-base) * 16 / 18); margin: 0; }
|
|
142
148
|
.archive-list .post-card-meta { grid-area: 1 / 2; display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 6px 16px; margin: 0; color: var(--site-text-muted); font-size: var(--text-label); line-height: 1.4; }
|
|
143
149
|
.archive-list .archive-date { display: block; white-space: nowrap; }
|
|
144
150
|
.archive-list .post-byline { align-items: flex-end; min-width: 0; margin: 0; padding: 0; text-align: right; }
|
|
@@ -195,7 +201,7 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
|
|
|
195
201
|
.prose h3 { font-size: var(--text-h3); line-height: 1.4; font-weight: 600; letter-spacing: -.03em; margin: 1.7em 0 .6em; }
|
|
196
202
|
.prose h4 { font-size: var(--text-h4); line-height: 1.5; font-weight: 600; margin: 1.6em 0 .5em; }
|
|
197
203
|
.prose h5 { font-size: var(--text-h5); line-height: 1.5; }
|
|
198
|
-
.prose h6 { font-size: var(--
|
|
204
|
+
.prose h6 { font-size: calc(var(--heading-base) * 16 / 18); line-height: 1.5; }
|
|
199
205
|
.prose h3 a { text-decoration: none; }
|
|
200
206
|
.prose h3 a:hover { text-decoration: underline; }
|
|
201
207
|
.prose ul, .prose ol { padding-left: 1.4em; }
|
|
@@ -212,7 +218,7 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
|
|
|
212
218
|
.card { display: flex; flex-direction: column; align-items: start; padding: 26px; border-radius: 6px; background: var(--site-surface-hover); text-decoration: none; }
|
|
213
219
|
.card:hover { background: var(--site-surface-active); }
|
|
214
220
|
.card-eyebrow { font-size: calc(var(--text-base) * .6875); text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
|
|
215
|
-
.prose .card h3 { font-size: calc(var(--
|
|
221
|
+
.prose .card h3 { font-size: calc(var(--heading-base) * 22 / 18); margin: 12px 0 6px; }
|
|
216
222
|
.prose .card p { font-size: var(--text-summary); line-height: 1.7; color: var(--muted); margin: 0 0 20px; }
|
|
217
223
|
.card-link { font-size: var(--text-label); font-weight: 600; margin-top: auto; }
|
|
218
224
|
.site-footer { border-top: 1px solid var(--line); }
|
|
@@ -265,9 +271,11 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
|
|
|
265
271
|
.mobile-menu-panel a { flex: none; padding: 12px 0; font-size: var(--text-base); line-height: 1.4; overflow-wrap: anywhere; }
|
|
266
272
|
@media (prefers-reduced-motion: no-preference) { .mobile-navigation[open] .mobile-menu-panel { animation: mobile-menu-enter 180ms ease-out; } }
|
|
267
273
|
.blog-home { padding-block: 46px 64px; }
|
|
268
|
-
.blog-home:not(:has(>
|
|
269
|
-
.
|
|
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; }
|
|
270
277
|
.post-list { grid-template-columns: 1fr; gap: 48px; }
|
|
278
|
+
.related-posts { margin-top: 56px; padding-top: 32px; }
|
|
271
279
|
[data-home-layout='rows'] .post-list .post-card-layout { display: flex; align-items: stretch; gap: 20px; }
|
|
272
280
|
[data-home-layout='rows'] .post-list .post-card h3 { font-size: var(--text-h4); }
|
|
273
281
|
.archive { margin-top: 72px; }
|