@lettercamp/cli 0.1.10 → 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 +3 -1
- package/dist/code-block.js +8 -8
- package/dist/content-component-syntax.js +75 -0
- package/dist/content-components.js +244 -0
- package/dist/dev.js +20 -3
- package/dist/media-dimensions.js +4 -3
- package/dist/publication-links.js +7 -0
- package/dist/search-content.js +24 -0
- package/dist/text-styles.js +23 -0
- package/package.json +7 -1
- package/renderer/src/components/AuthorByline.astro +9 -2
- package/renderer/src/components/BlogHome.astro +8 -4
- package/renderer/src/components/Footer.astro +1 -1
- package/renderer/src/components/Header.astro +2 -2
- package/renderer/src/components/PostSearch.astro +30 -0
- package/renderer/src/components/RelatedPosts.astro +11 -0
- package/renderer/src/components/SocialLink.astro +1 -1
- package/renderer/src/layouts/SiteLayout.astro +11 -4
- package/renderer/src/lib/block-widths.ts +0 -6
- package/renderer/src/lib/branding.js +2 -0
- package/renderer/src/lib/button.js +11 -9
- package/renderer/src/lib/code-block.ts +8 -8
- package/renderer/src/lib/faq.ts +25 -0
- package/renderer/src/lib/font-catalog.js +1650 -0
- package/renderer/src/lib/font-fallbacks.js +36 -0
- package/renderer/src/lib/fonts.js +31 -8
- package/renderer/src/lib/image-widths.ts +0 -3
- package/renderer/src/lib/key-takeaways.ts +25 -0
- package/renderer/src/lib/post-search.ts +194 -0
- package/renderer/src/lib/post-share.ts +10 -0
- package/renderer/src/lib/related-posts.ts +50 -0
- package/renderer/src/lib/site-colors.ts +42 -0
- package/renderer/src/lib/table-styles.ts +3 -3
- package/renderer/src/pages/[...slug].astro +14 -3
- package/renderer/src/styles/post-search.css +37 -0
- package/renderer/src/styles/post-share.css +44 -27
- package/renderer/src/styles/site.css +95 -72
|
@@ -193,4 +193,40 @@ export const fontFallbacks = {
|
|
|
193
193
|
{ "weight": 600, "local": ["Arial Bold", "Arial-BoldMT"], "sizeAdjust": "97.2956%", "ascentOverride": "101.1351%", "descentOverride": "32.4783%", "lineGapOverride": "0.0000%" },
|
|
194
194
|
{ "weight": 700, "local": ["Arial Bold", "Arial-BoldMT"], "sizeAdjust": "99.5874%", "ascentOverride": "98.8077%", "descentOverride": "31.7309%", "lineGapOverride": "0.0000%" },
|
|
195
195
|
] },
|
|
196
|
+
"Geist Mono": { generic: "monospace", faces: [
|
|
197
|
+
{ "weight": 400, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "100.5164%", "descentOverride": "29.5048%", "lineGapOverride": "0.0000%" },
|
|
198
|
+
{ "weight": 500, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "100.5164%", "descentOverride": "29.5048%", "lineGapOverride": "0.0000%" },
|
|
199
|
+
{ "weight": 600, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "100.5164%", "descentOverride": "29.5048%", "lineGapOverride": "0.0000%" },
|
|
200
|
+
{ "weight": 700, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "100.5164%", "descentOverride": "29.5048%", "lineGapOverride": "0.0000%" },
|
|
201
|
+
] },
|
|
202
|
+
"JetBrains Mono": { generic: "monospace", faces: [
|
|
203
|
+
{ "weight": 400, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.0166%", "descentOverride": "30.0049%", "lineGapOverride": "0.0000%" },
|
|
204
|
+
{ "weight": 500, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.0166%", "descentOverride": "30.0049%", "lineGapOverride": "0.0000%" },
|
|
205
|
+
{ "weight": 600, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.0166%", "descentOverride": "30.0049%", "lineGapOverride": "0.0000%" },
|
|
206
|
+
{ "weight": 700, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.0166%", "descentOverride": "30.0049%", "lineGapOverride": "0.0000%" },
|
|
207
|
+
] },
|
|
208
|
+
"Fira Code": { generic: "monospace", faces: [
|
|
209
|
+
{ "weight": 400, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "99.0161%", "descentOverride": "32.2052%", "lineGapOverride": "0.0000%" },
|
|
210
|
+
{ "weight": 500, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "99.0161%", "descentOverride": "32.2052%", "lineGapOverride": "0.0000%" },
|
|
211
|
+
{ "weight": 600, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "99.0161%", "descentOverride": "32.2052%", "lineGapOverride": "0.0000%" },
|
|
212
|
+
{ "weight": 700, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "99.0161%", "descentOverride": "32.2052%", "lineGapOverride": "0.0000%" },
|
|
213
|
+
] },
|
|
214
|
+
"Roboto Mono": { generic: "monospace", faces: [
|
|
215
|
+
{ "weight": 400, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "100.0000%", "ascentOverride": "104.7852%", "descentOverride": "27.0996%", "lineGapOverride": "0.0000%" },
|
|
216
|
+
{ "weight": 500, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "100.0000%", "ascentOverride": "104.7852%", "descentOverride": "27.0996%", "lineGapOverride": "0.0000%" },
|
|
217
|
+
{ "weight": 600, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "100.0000%", "ascentOverride": "104.7852%", "descentOverride": "27.0996%", "lineGapOverride": "0.0000%" },
|
|
218
|
+
{ "weight": 700, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "100.0000%", "ascentOverride": "104.7852%", "descentOverride": "27.0996%", "lineGapOverride": "0.0000%" },
|
|
219
|
+
] },
|
|
220
|
+
"IBM Plex Mono": { generic: "monospace", faces: [
|
|
221
|
+
{ "weight": 400, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.5167%", "descentOverride": "27.5045%", "lineGapOverride": "0.0000%" },
|
|
222
|
+
{ "weight": 500, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.5167%", "descentOverride": "27.5045%", "lineGapOverride": "0.0000%" },
|
|
223
|
+
{ "weight": 600, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.5167%", "descentOverride": "27.5045%", "lineGapOverride": "0.0000%" },
|
|
224
|
+
{ "weight": 700, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "102.5167%", "descentOverride": "27.5045%", "lineGapOverride": "0.0000%" },
|
|
225
|
+
] },
|
|
226
|
+
"Source Code Pro": { generic: "monospace", faces: [
|
|
227
|
+
{ "weight": 400, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "98.4160%", "descentOverride": "27.3044%", "lineGapOverride": "0.0000%" },
|
|
228
|
+
{ "weight": 500, "local": ["Courier New", "CourierNewPSMT"], "sizeAdjust": "99.9837%", "ascentOverride": "98.4160%", "descentOverride": "27.3044%", "lineGapOverride": "0.0000%" },
|
|
229
|
+
{ "weight": 600, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "98.4160%", "descentOverride": "27.3044%", "lineGapOverride": "0.0000%" },
|
|
230
|
+
{ "weight": 700, "local": ["Courier New Bold", "CourierNewPS-BoldMT"], "sizeAdjust": "99.9837%", "ascentOverride": "98.4160%", "descentOverride": "27.3044%", "lineGapOverride": "0.0000%" },
|
|
231
|
+
] },
|
|
196
232
|
};
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Generated from lettercamp/lib/font-resources.ts.
|
|
2
2
|
import { fontFallbacks } from "./font-fallbacks.js";
|
|
3
|
-
|
|
3
|
+
import { fontCatalog } from "./font-catalog.js";
|
|
4
|
+
export function fontResources(typography = {}, headingWeight = 500) {
|
|
4
5
|
const quote = (value) => '"' + Array.from(String(value), char => /[a-zA-Z0-9 -]/.test(char) ? char : '\\' + char.codePointAt(0).toString(16) + ' ').join('') + '"';
|
|
5
6
|
const google = new Set();
|
|
7
|
+
const resolved = new Set();
|
|
8
|
+
const preloads = new Set();
|
|
6
9
|
const custom = new Map();
|
|
7
10
|
const faces = [];
|
|
8
|
-
const resolve = (input) => {
|
|
11
|
+
const resolve = (input, criticalWeight) => {
|
|
9
12
|
const value = input && typeof input === "object" ? input : undefined;
|
|
10
13
|
if (value && typeof value.url === 'string' && !(typeof value.google === 'string' && value.google.trim())) {
|
|
11
14
|
try {
|
|
@@ -24,25 +27,45 @@ export function fontResources(typography = {}) {
|
|
|
24
27
|
catch { /* Invalid custom URLs use the default family. */ }
|
|
25
28
|
}
|
|
26
29
|
const family = value && typeof value.google === 'string' && value.google.trim() ? value.google.trim() : 'Inter';
|
|
30
|
+
const catalog = Object.hasOwn(fontCatalog, family) ? fontCatalog[family] : undefined;
|
|
31
|
+
if (catalog) {
|
|
32
|
+
if (!resolved.has(family))
|
|
33
|
+
for (const face of catalog.faces) {
|
|
34
|
+
faces.push('@font-face{font-family:' + quote(family) + ';src:url(' + quote(face.url) + ') format("woff2");font-weight:' + face.weight
|
|
35
|
+
+ ';font-style:normal;font-display:swap;unicode-range:' + face.unicodeRange + ';}');
|
|
36
|
+
}
|
|
37
|
+
// Prioritize only the title and body Latin files. Other subsets load on demand.
|
|
38
|
+
const distance = (weight) => {
|
|
39
|
+
const [min, max = min] = weight.split(' ').map(Number);
|
|
40
|
+
return Math.max(min - criticalWeight, criticalWeight - max, 0);
|
|
41
|
+
};
|
|
42
|
+
const critical = catalog.faces.filter(face => face.subset === 'latin').sort((a, b) => distance(a.weight) - distance(b.weight))[0];
|
|
43
|
+
if (critical)
|
|
44
|
+
preloads.add(critical.url);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// Preserve support for existing CLI settings naming fonts outside our picker.
|
|
48
|
+
google.add(family);
|
|
49
|
+
}
|
|
27
50
|
const fallback = Object.hasOwn(fontFallbacks, family) ? fontFallbacks[family] : undefined;
|
|
28
51
|
if (fallback) {
|
|
29
52
|
const alias = family + ' Fallback';
|
|
30
|
-
if (!
|
|
53
|
+
if (!resolved.has(family))
|
|
31
54
|
for (const face of fallback.faces) {
|
|
32
55
|
faces.push('@font-face{font-family:' + quote(alias) + ';src:' + face.local.map(name => 'local(' + quote(name) + ')').join(',')
|
|
33
56
|
+ ';font-weight:' + face.weight + ';font-style:normal;size-adjust:' + face.sizeAdjust
|
|
34
57
|
+ ';ascent-override:' + face.ascentOverride + ';descent-override:' + face.descentOverride + ';line-gap-override:' + face.lineGapOverride + ';}');
|
|
35
58
|
}
|
|
36
|
-
|
|
59
|
+
resolved.add(family);
|
|
37
60
|
return quote(family) + ', ' + quote(alias) + ', ' + fallback.generic;
|
|
38
61
|
}
|
|
39
|
-
|
|
62
|
+
resolved.add(family);
|
|
40
63
|
return quote(family) + ', Arial, Helvetica, sans-serif';
|
|
41
64
|
};
|
|
42
|
-
const headingFamily = resolve(typography.headingFont);
|
|
43
|
-
const bodyFamily = resolve(typography.bodyFont);
|
|
65
|
+
const headingFamily = resolve(typography.headingFont, headingWeight);
|
|
66
|
+
const bodyFamily = resolve(typography.bodyFont, 400);
|
|
44
67
|
return {
|
|
45
|
-
headingFamily, bodyFamily, css: faces.join('\n'),
|
|
68
|
+
headingFamily, bodyFamily, css: faces.join('\n'), preloads: [...preloads],
|
|
46
69
|
googleUrl: google.size ? 'https://fonts.googleapis.com/css2?' + [...google].map(family => 'family=' + encodeURIComponent(family) + ':wght@' + (Object.hasOwn(fontFallbacks, family) ? fontFallbacks[family].faces.map(face => face.weight).join(';') : '400;500;600;700')).join('&') + '&display=swap' : null,
|
|
47
70
|
};
|
|
48
71
|
}
|
|
@@ -7,7 +7,4 @@ export const imageWidthStyles = `
|
|
|
7
7
|
.publication-post .article-hero-image:not([data-image-width="regular"]):not([data-image-width="full"]) { width: min(100%, calc(var(--post-content-width) + 128px)); max-width: none; }
|
|
8
8
|
.publication-post .article-hero-image[data-image-width="full"] { width: 100%; max-width: none; }
|
|
9
9
|
.publication-post .article-hero-image[data-image-width="regular"] { width: min(100%, var(--cover-image-width, var(--post-content-width)), var(--post-content-width)); max-width: none; }
|
|
10
|
-
.publication-post .article-hero:has(.article-hero-image:not([data-image-width="regular"])) { display: block; }
|
|
11
|
-
.publication-post .article-hero:has(.article-hero-image:not([data-image-width="regular"])) .article-intro { max-width: var(--post-content-width); margin-inline: auto; }
|
|
12
|
-
.publication-post .article-hero:has(.article-hero-image:not([data-image-width="regular"])) .article-hero-image { margin-top: 32px; }
|
|
13
10
|
`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/key-takeaways.ts.
|
|
2
|
+
// Shared by the page editor, draft preview, and published renderer.
|
|
3
|
+
export const keyTakeawaysStyles = `
|
|
4
|
+
[data-lettercamp-key-takeaways] {
|
|
5
|
+
box-sizing: border-box; margin-block: 24px; padding: 20px 24px;
|
|
6
|
+
border-radius: 8px; background: var(--site-surface, #fafafa); color: inherit;
|
|
7
|
+
font: inherit; overflow-wrap: anywhere;
|
|
8
|
+
}
|
|
9
|
+
[data-lettercamp-key-takeaways][data-lettercamp-key-takeaways] > :is(h1, h2, h3, h4, h5, h6) {
|
|
10
|
+
margin: 0 0 10px; padding: 0;
|
|
11
|
+
}
|
|
12
|
+
[data-lettercamp-key-takeaways][data-lettercamp-key-takeaways] :is(ul, ol) {
|
|
13
|
+
margin: 0; padding: 0 0 0 1.4em;
|
|
14
|
+
}
|
|
15
|
+
[data-lettercamp-key-takeaways][data-lettercamp-key-takeaways] li {
|
|
16
|
+
margin: 6px 0; padding: 0 0 0 .2em;
|
|
17
|
+
}
|
|
18
|
+
[data-lettercamp-key-takeaways][data-lettercamp-key-takeaways] p { margin: 0; padding: 0; min-height: 0; }
|
|
19
|
+
[data-lettercamp-key-takeaways][data-lettercamp-key-takeaways] > p:first-child { margin-bottom: 10px; }
|
|
20
|
+
[data-lettercamp-key-takeaways][data-lettercamp-key-takeaways] > :last-child,
|
|
21
|
+
[data-lettercamp-key-takeaways][data-lettercamp-key-takeaways] > :last-child > li:last-child { margin-bottom: 0; }
|
|
22
|
+
@media (max-width: 600px) {
|
|
23
|
+
[data-lettercamp-key-takeaways] { padding: 16px 18px; }
|
|
24
|
+
}
|
|
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
|
+
})();`;
|
|
@@ -31,6 +31,16 @@ export const postShareScript = `
|
|
|
31
31
|
const ensureTrigger = () => {
|
|
32
32
|
const byline = document.querySelector('.publication-post .byline');
|
|
33
33
|
if (byline && !byline.querySelector('.post-share-button')) byline.insertAdjacentHTML('beforeend', triggerMarkup);
|
|
34
|
+
const intro = document.querySelector('.publication-post .article-intro');
|
|
35
|
+
if (!intro) return;
|
|
36
|
+
let row = intro.querySelector('.article-meta-row');
|
|
37
|
+
if (!row) {
|
|
38
|
+
row = document.createElement('div'); row.className = 'article-meta-row';
|
|
39
|
+
const category = intro.querySelector('.article-category');
|
|
40
|
+
if (category) row.append(category);
|
|
41
|
+
intro.prepend(row);
|
|
42
|
+
}
|
|
43
|
+
if (!row.querySelector('.post-share-button')) row.insertAdjacentHTML('beforeend', triggerMarkup);
|
|
34
44
|
};
|
|
35
45
|
const action = (tag, name, label, graphic) => '<' + tag + ' class="post-share-action" data-share="' + name + '"' + (tag === 'button' ? ' type="button"' : '') + '><span class="post-share-action-icon">' + graphic + '</span><span data-label>' + label + '</span></' + tag + '>';
|
|
36
46
|
const external = (link, href) => {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/site-colors.ts.
|
|
2
|
+
// Shared by published sites and live previews. Keep this function self-contained:
|
|
3
|
+
// the preview bridge embeds it in the publication iframe.
|
|
4
|
+
export function siteColors(design?: { backgroundColor?: unknown; accentColor?: unknown }): Record<string, string> {
|
|
5
|
+
const [background, accent] = [design?.backgroundColor, design?.accentColor].map((value, index) =>
|
|
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 => {
|
|
8
|
+
const linear = [1, 3, 5].map(offset => {
|
|
9
|
+
const channel = parseInt(color.slice(offset, offset + 2), 16) / 255;
|
|
10
|
+
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
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";
|
|
14
|
+
});
|
|
15
|
+
const base = [1, 3, 5].map(offset => parseInt(background.slice(offset, offset + 2), 16));
|
|
16
|
+
const target = text === "#000000" ? 0 : 255;
|
|
17
|
+
// Reference grays on white. Retain each shade's distance along the
|
|
18
|
+
// background-to-foreground scale; e.g. 250 preserves #fafafa on white.
|
|
19
|
+
const shades: Record<string, number> = {
|
|
20
|
+
"surface": 250,
|
|
21
|
+
"surface-hover": 245,
|
|
22
|
+
"surface-active": 238,
|
|
23
|
+
"border": 240,
|
|
24
|
+
"border-strong": 221,
|
|
25
|
+
"text-strong": 65,
|
|
26
|
+
"text-secondary": 111,
|
|
27
|
+
"text-muted": 134,
|
|
28
|
+
"text-faint": 180,
|
|
29
|
+
};
|
|
30
|
+
const colors: Record<string, string> = {
|
|
31
|
+
"color-scheme": target === 255 ? "dark" : "light",
|
|
32
|
+
"--site-background": background,
|
|
33
|
+
"--site-foreground": text,
|
|
34
|
+
"--site-accent": accent || "inherit",
|
|
35
|
+
"--site-accent-text": accent ? accentText : "inherit",
|
|
36
|
+
"--site-code-tint": target === 255 ? "45%" : "0%",
|
|
37
|
+
};
|
|
38
|
+
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("");
|
|
40
|
+
}
|
|
41
|
+
return colors;
|
|
42
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Generated from lettercamp/lib/table-styles.ts.
|
|
2
2
|
// Shared by the editable table, safe preview, and published renderer.
|
|
3
3
|
export const tableStyles = `
|
|
4
|
-
[data-lettercamp-table] { position: relative; margin-block: 24px 32px; color: #303034; font: inherit; }
|
|
4
|
+
[data-lettercamp-table] { position: relative; margin-block: 24px 32px; color: var(--site-text-strong, #303034); font: inherit; }
|
|
5
5
|
[data-lettercamp-table] > table { width: 100%; border-collapse: collapse; table-layout: fixed; border-spacing: 0; font: inherit; color: inherit; }
|
|
6
|
-
[data-lettercamp-table] > table > tbody > tr > :is(td, th) { position: relative; border: 1px solid #f0f0f0; padding: 0; min-width: 0; vertical-align: top; font: inherit; text-align: left; }
|
|
6
|
+
[data-lettercamp-table] > table > tbody > tr > :is(td, th) { position: relative; border: 1px solid var(--site-border, #f0f0f0); padding: 0; min-width: 0; vertical-align: top; font: inherit; text-align: left; }
|
|
7
7
|
[data-lettercamp-table] [data-table-content] { display: block; box-sizing: border-box; min-height: calc(1.8em + 16px); padding: 8px 12px; line-height: inherit; overflow-wrap: anywhere; white-space: pre-wrap; outline: none; }
|
|
8
8
|
[data-lettercamp-table] [data-table-cell-height] [data-table-content] { height: var(--table-cell-height); min-height: 0; overflow: auto; }
|
|
9
9
|
[data-lettercamp-table] :is(td, th) > p, [data-lettercamp-table] [data-table-content] > p { margin: 0; padding: 0; min-height: 0; }
|
|
10
|
-
[data-lettercamp-table] [data-table-content] code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; padding: .18em .35em; background: #f3f3f3; border-radius: 3px; }
|
|
10
|
+
[data-lettercamp-table] [data-table-content] code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; padding: .18em .35em; background: var(--site-surface-hover, #f3f3f3); border-radius: 3px; }
|
|
11
11
|
[data-lettercamp-table] [data-table-content] a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
|
|
12
12
|
`;
|
|
@@ -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));
|
|
@@ -35,9 +42,12 @@ const coverWidth = /^[0-9]+$/.test(String(frontmatter.coverWidth)) && Number(fro
|
|
|
35
42
|
data-post-cover={postLayout.showCover === false ? 'hidden' : 'visible'} data-post-toc={postLayout.showTableOfContents === false ? 'hidden' : 'visible'}>
|
|
36
43
|
<header class:list={['article-header', 'article-hero', { 'article-hero-with-image': coverImage }]}>
|
|
37
44
|
<div class="article-intro">
|
|
45
|
+
<div class="article-meta-row">
|
|
38
46
|
{category && <p class="article-category">
|
|
39
47
|
<a href={categoryHref}>{category}</a>
|
|
40
48
|
</p>}
|
|
49
|
+
<Fragment set:html={postShareButton} />
|
|
50
|
+
</div>
|
|
41
51
|
<h1>{frontmatter.title ?? site.name}</h1>
|
|
42
52
|
{frontmatter.description && <p class="description">{frontmatter.description}</p>}
|
|
43
53
|
<div class="byline">
|
|
@@ -62,5 +72,6 @@ const coverWidth = /^[0-9]+$/.test(String(frontmatter.coverWidth)) && Number(fro
|
|
|
62
72
|
</aside>}
|
|
63
73
|
<div class="prose"><Content /></div>
|
|
64
74
|
</div>
|
|
75
|
+
<RelatedPosts posts={relatedPosts} site={site} />
|
|
65
76
|
</article>
|
|
66
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); }
|