@lettercamp/cli 0.1.12 → 0.1.14
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/package.json +1 -1
- package/renderer/src/components/PostSearch.astro +2 -1
- package/renderer/src/layouts/SiteLayout.astro +2 -2
- package/renderer/src/lib/metadata.js +23 -5
- package/renderer/src/lib/post-search.ts +19 -14
- package/renderer/src/pages/index.astro +2 -1
- package/renderer/src/styles/post-search.css +12 -6
- package/renderer/src/styles/site.css +2 -2
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ const { posts } = Astro.props;
|
|
|
6
6
|
// inert JSON script: article text may contain a literal closing script tag.
|
|
7
7
|
const index = JSON.stringify(posts.map(post => ({
|
|
8
8
|
title: String(post.title), href: post.href,
|
|
9
|
+
image: post.image ?? post.ogImage,
|
|
9
10
|
summary: typeof post.description === 'string' ? post.description : '',
|
|
10
11
|
text: typeof post.searchText === 'string' ? post.searchText : '',
|
|
11
12
|
category: post.category, date: post.date.label,
|
|
@@ -14,7 +15,7 @@ const index = JSON.stringify(posts.map(post => ({
|
|
|
14
15
|
|
|
15
16
|
<div class="post-search-trigger" hidden>
|
|
16
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>
|
|
17
|
-
<
|
|
18
|
+
<button type="button" aria-label="Search articles" aria-haspopup="dialog" aria-controls="post-search-dialog">Search articles…</button>
|
|
18
19
|
</div>
|
|
19
20
|
<dialog id="post-search-dialog" class="post-search-dialog" aria-label="Search articles">
|
|
20
21
|
<div class="post-search-header">
|
|
@@ -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 = [] } = 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;
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
// Generated from lettercamp/lib/page-metadata.ts.
|
|
2
|
-
|
|
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
|
|
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
|
|
25
|
-
const
|
|
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 = "";
|
|
@@ -3,7 +3,7 @@ export const postSearchScript = String.raw`(() => {
|
|
|
3
3
|
const dialog = document.querySelector('#post-search-dialog');
|
|
4
4
|
const data = document.querySelector('[data-post-search-index]');
|
|
5
5
|
if (!trigger || !dialog || !data || typeof dialog.showModal !== 'function') return;
|
|
6
|
-
const opener = trigger.querySelector('
|
|
6
|
+
const opener = trigger.querySelector('button');
|
|
7
7
|
const input = dialog.querySelector('input');
|
|
8
8
|
const results = dialog.querySelector('.post-search-results');
|
|
9
9
|
const status = dialog.querySelector('.post-search-status');
|
|
@@ -13,7 +13,6 @@ export const postSearchScript = String.raw`(() => {
|
|
|
13
13
|
const normalized = [];
|
|
14
14
|
let generation = 0;
|
|
15
15
|
let timer;
|
|
16
|
-
let restoringFocus = false;
|
|
17
16
|
const normalize = text => text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
18
17
|
const element = (tag, className, text) => {
|
|
19
18
|
const node = document.createElement(tag);
|
|
@@ -80,11 +79,24 @@ export const postSearchScript = String.raw`(() => {
|
|
|
80
79
|
const row = document.createElement('li');
|
|
81
80
|
const link = element('a', 'post-search-result', '');
|
|
82
81
|
link.href = post.href;
|
|
83
|
-
|
|
82
|
+
if (post.image) {
|
|
83
|
+
const image = element('img', 'post-search-image', '');
|
|
84
|
+
image.src = post.image;
|
|
85
|
+
image.alt = '';
|
|
86
|
+
image.width = 1200;
|
|
87
|
+
image.height = 630;
|
|
88
|
+
image.loading = 'lazy';
|
|
89
|
+
image.decoding = 'async';
|
|
90
|
+
image.addEventListener('error', () => image.remove(), { once: true });
|
|
91
|
+
link.append(image);
|
|
92
|
+
}
|
|
93
|
+
const content = element('span', 'post-search-content', '');
|
|
94
|
+
content.append(highlighted('post-search-title', post.title, terms));
|
|
84
95
|
const meta = [post.category, post.date].filter(Boolean).join(' · ');
|
|
85
|
-
if (meta)
|
|
96
|
+
if (meta) content.append(element('span', 'post-search-meta', meta));
|
|
86
97
|
const excerpt = terms.length ? snippet(post, terms, normalized[index]) : post.summary;
|
|
87
|
-
if (excerpt)
|
|
98
|
+
if (excerpt) content.append(highlighted('post-search-excerpt', excerpt, terms));
|
|
99
|
+
link.append(content);
|
|
88
100
|
row.append(link);
|
|
89
101
|
fragment.append(row);
|
|
90
102
|
}
|
|
@@ -93,7 +105,7 @@ export const postSearchScript = String.raw`(() => {
|
|
|
93
105
|
results.removeAttribute('aria-busy');
|
|
94
106
|
empty.hidden = count > 0;
|
|
95
107
|
empty.textContent = posts.length ? 'No articles found. Try a different word or title.' : 'No articles here yet.';
|
|
96
|
-
status.textContent = !terms.length ? '
|
|
108
|
+
status.textContent = !terms.length ? '' : count > limit
|
|
97
109
|
? 'Showing ' + limit + ' of ' + count + ' matching articles. Keep typing to narrow your search.'
|
|
98
110
|
: count + (count === 1 ? ' article found' : ' articles found');
|
|
99
111
|
}
|
|
@@ -143,7 +155,7 @@ export const postSearchScript = String.raw`(() => {
|
|
|
143
155
|
timer = setTimeout(step, 0);
|
|
144
156
|
}
|
|
145
157
|
function open() {
|
|
146
|
-
if (
|
|
158
|
+
if (dialog.open) return;
|
|
147
159
|
if (!posts) {
|
|
148
160
|
posts = JSON.parse(data.textContent);
|
|
149
161
|
data.textContent = '';
|
|
@@ -153,19 +165,12 @@ export const postSearchScript = String.raw`(() => {
|
|
|
153
165
|
search();
|
|
154
166
|
}
|
|
155
167
|
function close() {
|
|
156
|
-
restoringFocus = true;
|
|
157
168
|
++generation;
|
|
158
169
|
clearTimeout(timer);
|
|
159
170
|
dialog.close();
|
|
160
171
|
opener.focus({ preventScroll: true });
|
|
161
|
-
// Native dialog focus restoration must not immediately reopen the search.
|
|
162
|
-
queueMicrotask(() => { restoringFocus = false; });
|
|
163
172
|
}
|
|
164
|
-
opener.addEventListener('focus', open);
|
|
165
173
|
opener.addEventListener('click', open);
|
|
166
|
-
opener.addEventListener('keydown', event => {
|
|
167
|
-
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); }
|
|
168
|
-
});
|
|
169
174
|
input.addEventListener('input', event => { if (!event.isComposing) search(); });
|
|
170
175
|
input.addEventListener('compositionend', search);
|
|
171
176
|
dialog.querySelector('.post-search-close').addEventListener('click', close);
|
|
@@ -2,9 +2,10 @@
|
|
|
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`;
|
|
6
7
|
---
|
|
7
8
|
|
|
8
|
-
<SiteLayout site={site} page={{ title }}>
|
|
9
|
+
<SiteLayout site={site} page={{ title }} posts={getPosts(site.language, site.timeZone)}>
|
|
9
10
|
<BlogHome site={site} />
|
|
10
11
|
</SiteLayout>
|
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
.post-search-trigger { position: relative; flex: 0 1 240px; min-width: 180px; margin-left: auto; }
|
|
2
|
+
.blog-toolbar:not(:has(> .category-selector:not([hidden]))) > .post-search-trigger { margin-left: 0; }
|
|
2
3
|
.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
|
|
4
|
-
.post-search-trigger
|
|
5
|
-
.post-search-
|
|
4
|
+
.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; }
|
|
5
|
+
.post-search-trigger button:hover { background: var(--site-surface-active); color: var(--site-foreground); }
|
|
6
|
+
.post-search-header input::placeholder { color: var(--site-text-secondary); opacity: 1; }
|
|
7
|
+
.post-search-trigger button:focus-visible { outline: 1px solid var(--site-border-strong); outline-offset: 3px; border-radius: 999px; background: var(--site-surface-active); }
|
|
6
8
|
html:has(.post-search-dialog[open]) { overflow: hidden; }
|
|
7
9
|
.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
10
|
.post-search-dialog[open] { display: flex; flex-direction: column; }
|
|
9
11
|
.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-
|
|
12
|
+
.post-search-header { display: flex; flex-shrink: 0; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--site-surface, #fafafa); }
|
|
11
13
|
.post-search-header > svg { width: 20px; height: 20px; flex-shrink: 0; color: var(--site-text-secondary); }
|
|
12
14
|
.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
15
|
.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
16
|
.post-search-close:hover { color: var(--site-foreground); }
|
|
16
17
|
.post-search-close svg { width: 20px; height: 20px; }
|
|
17
18
|
.post-search-status { flex-shrink: 0; margin: 0; padding: 8px 16px 4px; color: var(--site-text-secondary); font-size: var(--text-meta); }
|
|
19
|
+
.post-search-status:empty { padding: 0; }
|
|
18
20
|
.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;
|
|
21
|
+
.post-search-result { display: flex; align-items: flex-start; gap: 12px; padding: 8px 10px; border-radius: 8px; color: inherit; text-decoration: none; overflow-wrap: anywhere; }
|
|
22
|
+
.post-search-image { flex: 0 0 64px; width: 64px; height: auto; aspect-ratio: 1200 / 630; object-fit: cover; border-radius: 4px; background: var(--site-surface); }
|
|
23
|
+
.post-search-content { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 2px; }
|
|
20
24
|
.post-search-result:is(:hover, :focus-visible) { outline: none; background: var(--site-surface-active); border-radius: 8px; }
|
|
21
25
|
.post-search-title { font-size: var(--text-label); font-weight: 500; }
|
|
22
26
|
.post-search-meta { color: var(--site-text-secondary); font-size: var(--text-meta); }
|
|
@@ -27,6 +31,8 @@ html:has(.post-search-dialog[open]) { overflow: hidden; }
|
|
|
27
31
|
.post-search-trigger { flex-basis: 100%; min-width: 0; }
|
|
28
32
|
.post-search-dialog { width: calc(100vw - 24px); max-height: min(440px, calc(100dvh - 24px)); }
|
|
29
33
|
.post-search-header { padding: 10px 12px; }
|
|
34
|
+
.post-search-result { gap: 10px; }
|
|
35
|
+
.post-search-image { flex-basis: 56px; width: 56px; }
|
|
30
36
|
}
|
|
31
37
|
@media (prefers-reduced-motion: no-preference) {
|
|
32
38
|
.post-search-dialog[open] { animation: post-search-enter 140ms ease-out; }
|
|
@@ -73,7 +73,7 @@ html:has(.site-header[data-header-position='fixed']) { scroll-padding-top: 80px;
|
|
|
73
73
|
.mobile-menu-toggle svg { transition: none; }
|
|
74
74
|
}
|
|
75
75
|
.blog-home { padding-block: 72px 100px; }
|
|
76
|
-
.blog-heading { margin-bottom:
|
|
76
|
+
.blog-heading { margin-bottom: 40px; }
|
|
77
77
|
.blog-home:not(:has(.blog-toolbar > :not([hidden], script, dialog))) > .blog-heading { margin-bottom: 64px; }
|
|
78
78
|
.blog-heading h1 { margin-bottom: 0; }
|
|
79
79
|
.article-category { margin: 0 0 20px; }
|
|
@@ -272,7 +272,7 @@ h1 { font-size: var(--text-title); font-weight: 500; line-height: 1.15; letter-s
|
|
|
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
274
|
.blog-home:not(:has(.blog-toolbar > :not([hidden], script, dialog))) > .blog-heading { margin-bottom: 40px; }
|
|
275
|
-
.blog-toolbar { margin-bottom: 36px; }
|
|
275
|
+
.blog-heading, .blog-toolbar { margin-bottom: 36px; }
|
|
276
276
|
.category-selector { gap: 4px 20px; }
|
|
277
277
|
.post-list { grid-template-columns: 1fr; gap: 48px; }
|
|
278
278
|
.related-posts { margin-top: 56px; padding-top: 32px; }
|