@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lettercamp/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "The repository for the Lettercamp CLI",
5
5
  "keywords": [
6
6
  "lettercamp",
@@ -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
- <input type="search" readonly placeholder="Search articles…" aria-label="Search articles" aria-haspopup="dialog" aria-controls="post-search-dialog" />
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
- export function pageMetadata(site, page, url, article = false, basePath = "") {
2
+ const text = (value) => typeof value === "string" ? value.trim() : "";
3
+ function siteCover(site, posts) {
4
+ const timestamp = (value) => {
5
+ if (value && typeof value === "object" && "timestamp" in value && typeof value.timestamp === "number")
6
+ return value.timestamp;
7
+ const time = value ? new Date(String(value)).valueOf() : NaN;
8
+ return Number.isNaN(time) ? -Infinity : time;
9
+ };
10
+ const post = posts.filter(post => post.draft !== true && (text(post.image) || text(post.ogImage)))
11
+ .sort((a, b) => timestamp(b.date) - timestamp(a.date) || (a.href ?? "").localeCompare(b.href ?? ""))[0];
12
+ return post
13
+ ? { image: text(post.image) || text(post.ogImage), alt: (text(post.image) ? text(post.imageAlt) : text(post.ogImageAlt)) || text(post.title) }
14
+ : { image: text(site.logo), alt: text(site.name) };
15
+ }
16
+ export function pageMetadata(site, page, url, article = false, basePath = "", posts = []) {
3
17
  const seo = site.seo && typeof site.seo === "object" ? site.seo : {};
4
- const text = (value) => typeof value === "string" ? value.trim() : "";
18
+ const homepage = !article && new URL(url).pathname.replace(/\/+$/, "") === basePath.replace(/\/+$/, "");
19
+ // Homepage overrides belong to the blog root, not posts or category pages.
20
+ if (homepage)
21
+ page = { ...seo, ...page };
5
22
  const title = text(page.title) || text(site.name);
6
23
  const pageTitle = title === site.name ? title : `${title} — ${text(site.name)}`;
7
- const homepage = !article && new URL(url).pathname.replace(/\/+$/, "") === basePath.replace(/\/+$/, "");
8
24
  const documentTitle = text(page.metaTitle) || (homepage && text(seo.metaTitle)) || pageTitle;
9
25
  const description = text(page.metaDescription) || text(page.description) || text(site.description);
10
26
  const socialTitle = text(page.ogTitle) || (homepage && (text(seo.ogTitle) || text(seo.metaTitle))) || title;
@@ -21,8 +37,10 @@ export function pageMetadata(site, page, url, article = false, basePath = "") {
21
37
  return "";
22
38
  }
23
39
  };
24
- const imageUrl = resolveUrl(page.ogImage || page.image || seo.ogImage);
25
- const imageAlt = text(page.ogImageAlt) || (page.ogImage || page.image ? text(page.imageAlt) : text(seo.ogImageAlt));
40
+ const explicitImage = text(page.ogImage) || text(page.image) || text(seo.ogImage);
41
+ const fallback = homepage && !explicitImage ? siteCover(site, posts) : undefined;
42
+ const imageUrl = resolveUrl(explicitImage || fallback?.image);
43
+ const imageAlt = text(page.ogImageAlt) || (fallback ? fallback.alt : page.ogImage || page.image ? text(page.imageAlt) : text(seo.ogImageAlt));
26
44
  const twitterImageUrl = resolveUrl(page.twitterImage) || imageUrl;
27
45
  const language = text(page.language) || text(site.language) || "en";
28
46
  let locale = "";
@@ -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('input');
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
- link.append(highlighted('post-search-title', post.title, terms));
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) link.append(element('span', 'post-search-meta', 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) link.append(highlighted('post-search-excerpt', excerpt, terms));
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 ? 'Recent articles' : count > limit
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 (restoringFocus || dialog.open) return;
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 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; }
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-border); }
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; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 8px; color: inherit; text-decoration: none; overflow-wrap: anywhere; }
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: 18px; }
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; }