@lettercamp/cli 0.1.6 → 0.1.8

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.
Files changed (61) hide show
  1. package/README.md +3 -1
  2. package/dist/account.js +189 -0
  3. package/dist/build.js +24 -9
  4. package/dist/cli.js +35 -5
  5. package/dist/code-block.js +50 -0
  6. package/dist/code-detection.js +24 -0
  7. package/dist/code-highlight.js +54 -0
  8. package/dist/custom-layout.js +32 -0
  9. package/dist/deploy.js +131 -0
  10. package/dist/dev.js +52 -15
  11. package/dist/domain.js +190 -0
  12. package/dist/hosting.js +132 -0
  13. package/dist/lettercamp.js +2 -2
  14. package/dist/media-dimensions.js +154 -0
  15. package/dist/new.js +1 -1
  16. package/dist/ports.js +31 -0
  17. package/dist/preview.js +48 -0
  18. package/dist/prompts.js +31 -0
  19. package/dist/publication-links.js +23 -0
  20. package/dist/react.js +15 -0
  21. package/dist/reading-time.js +25 -0
  22. package/dist/upload.js +57 -0
  23. package/dist/workspace.js +8 -3
  24. package/package.json +12 -2
  25. package/renderer/src/components/AuthorByline.astro +26 -0
  26. package/renderer/src/components/BlogHome.astro +37 -0
  27. package/renderer/src/components/Footer.astro +35 -0
  28. package/renderer/src/components/Header.astro +38 -0
  29. package/renderer/src/components/MobileMenuIcons.astro +7 -0
  30. package/renderer/src/components/PostCard.astro +25 -0
  31. package/renderer/src/components/SocialLink.astro +23 -0
  32. package/renderer/src/layouts/SiteLayout.astro +73 -27
  33. package/renderer/src/lib/analytics.ts +21 -0
  34. package/renderer/src/lib/author-dialog.ts +102 -0
  35. package/renderer/src/lib/authors.js +18 -0
  36. package/renderer/src/lib/block-widths.ts +30 -0
  37. package/renderer/src/lib/branding.js +15 -0
  38. package/renderer/src/lib/button.js +10 -0
  39. package/renderer/src/lib/category-routes.js +19 -0
  40. package/renderer/src/lib/code-block.ts +46 -0
  41. package/renderer/src/lib/fonts.js +33 -0
  42. package/renderer/src/lib/home-layout.ts +5 -0
  43. package/renderer/src/lib/image-radius.ts +5 -0
  44. package/renderer/src/lib/image-rounding.ts +28 -0
  45. package/renderer/src/lib/image-widths.ts +13 -0
  46. package/renderer/src/lib/metadata.js +47 -0
  47. package/renderer/src/lib/mobile-navigation.ts +76 -0
  48. package/renderer/src/lib/post-layout.ts +6 -0
  49. package/renderer/src/lib/post-share.ts +264 -0
  50. package/renderer/src/lib/post-toc.ts +69 -0
  51. package/renderer/src/lib/posts.js +40 -0
  52. package/renderer/src/lib/publication-url.js +14 -0
  53. package/renderer/src/lib/table-styles.ts +12 -0
  54. package/renderer/src/lib/urls.js +3 -0
  55. package/renderer/src/pages/[...slug].astro +50 -25
  56. package/renderer/src/pages/category/[tag].astro +16 -0
  57. package/renderer/src/pages/category/index.astro +4 -0
  58. package/renderer/src/pages/index.astro +10 -0
  59. package/renderer/src/styles/post-share.css +57 -0
  60. package/renderer/src/styles/site.css +352 -51
  61. package/renderer/src/layouts/Content.astro +0 -1
@@ -0,0 +1,264 @@
1
+ const shareIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V3m-5 5 5-5 5 5M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7"/></svg>';
2
+ export const postShareButton = `<button type="button" class="post-share-button" aria-haspopup="dialog">${shareIcon}<span>Share</span></button>`;
3
+
4
+ // Read metadata when opening so the editor's live preview always shares the current post.
5
+ export const postShareScript = `
6
+ (() => {
7
+ const triggerMarkup = ${JSON.stringify(postShareButton)};
8
+ const icon = paths => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + '</svg>';
9
+ const icons = {
10
+ link: icon('<path d="m10 13 4-4m-6 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m2 3 1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0" transform="translate(1 1)"/>'),
11
+ x: icon('<path d="m4 3 16 18h-5L4 3h5l11 18M20 3l-6.5 7.3M4 21l6.5-7.3"/>'),
12
+ email: icon('<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 6 9 7 9-7"/>'),
13
+ summarize: icon('<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3Z"/>'),
14
+ close: icon('<path d="m6 6 12 12M6 18 18 6"/>'),
15
+ arrow: icon('<path d="M7 17 17 7M7 7h10v10"/>'),
16
+ more: icon('<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>'),
17
+ };
18
+ let dialog;
19
+ let trigger;
20
+ let copyGeneration = 0;
21
+ let sharedPost;
22
+ let menuTracking;
23
+ const meta = name => document.querySelector('meta[property="' + name + '"]')?.content || '';
24
+ const httpUrl = value => {
25
+ if (!value) return '';
26
+ try {
27
+ const url = new URL(value, location.href);
28
+ return ['https:', 'http:'].includes(url.protocol) ? url.href : '';
29
+ } catch { return ''; }
30
+ };
31
+ const ensureTrigger = () => {
32
+ const byline = document.querySelector('.publication-post .byline');
33
+ if (byline && !byline.querySelector('.post-share-button')) byline.insertAdjacentHTML('beforeend', triggerMarkup);
34
+ };
35
+ 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
+ const external = (link, href) => {
37
+ link.href = href;
38
+ link.target = '_blank';
39
+ link.rel = 'noopener noreferrer';
40
+ };
41
+ const createDialog = () => {
42
+ dialog = document.createElement('dialog');
43
+ dialog.className = 'post-share-dialog';
44
+ dialog.id = 'post-share-dialog';
45
+ dialog.setAttribute('aria-labelledby', 'post-share-heading');
46
+ dialog.innerHTML = '<div class="post-share-heading"><h2 id="post-share-heading">Share this post</h2><button type="button" class="post-share-close" aria-label="Close share dialog" autofocus>' + icons.close + '</button></div>' +
47
+ '<div class="post-share-preview" role="group" aria-label="Link preview"><img class="post-share-image" alt="" hidden><div class="post-share-preview-copy"><p class="post-share-domain"></p><h3></h3><p class="post-share-description"></p></div></div>' +
48
+ '<div class="post-share-actions">' + action('button', 'copy', 'Copy link', icons.link) + action('a', 'x', 'X', icons.x) + action('a', 'email', 'Email', icons.email) + action('button', 'summarize', 'Summarize', icons.summarize) + action('button', 'more', 'More', icons.more) + '</div>' +
49
+ '<div id="post-share-menu" class="post-share-menu" popover="auto" role="menu" aria-label="More sharing options"></div>' +
50
+ '<section id="post-share-summaries" class="post-share-summaries" aria-labelledby="post-share-summary-heading" hidden><h3 id="post-share-summary-heading">Summarize with AI</h3><p>Choose an app to open a ready-to-use prompt.</p><div class="post-share-providers"></div></section>' +
51
+ '<p class="post-share-status" role="status" aria-live="polite"></p><div class="post-share-copy-fallback" hidden><label for="post-share-url">Post link</label><input id="post-share-url" readonly></div>';
52
+ const moreButton = dialog.querySelector('[data-share="more"]');
53
+ const menu = dialog.querySelector('.post-share-menu');
54
+ moreButton.setAttribute('aria-haspopup', 'menu');
55
+ moreButton.setAttribute('aria-expanded', 'false');
56
+ moreButton.setAttribute('aria-controls', menu.id);
57
+ moreButton.setAttribute('popovertarget', menu.id);
58
+ const closeMenu = (restoreFocus = false) => {
59
+ if (!menu.matches(':popover-open')) return;
60
+ menu.hidePopover();
61
+ moreButton.setAttribute('aria-expanded', 'false');
62
+ menuTracking?.abort();
63
+ if (restoreFocus) moreButton.focus({ preventScroll: true });
64
+ };
65
+ const positionMenu = () => {
66
+ const anchor = moreButton.getBoundingClientRect();
67
+ const viewport = window.visualViewport;
68
+ const left = viewport?.offsetLeft || 0;
69
+ const top = viewport?.offsetTop || 0;
70
+ const width = viewport?.width || innerWidth;
71
+ const height = viewport?.height || innerHeight;
72
+ const above = anchor.top - top - 20;
73
+ const below = top + height - anchor.bottom - 20;
74
+ const upwards = above >= Math.min(menu.scrollHeight, 360) || above > below;
75
+ menu.style.maxHeight = Math.max(44, upwards ? above : below) + 'px';
76
+ menu.style.left = Math.max(left + 12, Math.min(anchor.right - menu.offsetWidth, left + width - menu.offsetWidth - 12)) + 'px';
77
+ menu.style.top = Math.max(top + 12, upwards ? anchor.top - menu.offsetHeight - 8 : anchor.bottom + 8) + 'px';
78
+ };
79
+ const openMenu = (last = false) => {
80
+ if (menu.matches(':popover-open')) return;
81
+ menu.showPopover();
82
+ moreButton.setAttribute('aria-expanded', 'true');
83
+ positionMenu();
84
+ const items = menu.querySelectorAll('[role="menuitem"]');
85
+ (last ? items[items.length - 1] : items[0])?.focus({ preventScroll: true });
86
+ menuTracking?.abort();
87
+ menuTracking = new AbortController();
88
+ const signal = menuTracking.signal;
89
+ window.addEventListener('resize', positionMenu, { signal, passive: true });
90
+ dialog.addEventListener('scroll', positionMenu, { signal, passive: true });
91
+ window.visualViewport?.addEventListener('resize', positionMenu, { signal, passive: true });
92
+ window.visualViewport?.addEventListener('scroll', positionMenu, { signal, passive: true });
93
+ };
94
+ moreButton.addEventListener('click', event => {
95
+ event.preventDefault();
96
+ menu.matches(':popover-open') ? closeMenu(true) : openMenu();
97
+ });
98
+ moreButton.addEventListener('keydown', event => {
99
+ if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
100
+ event.preventDefault(); openMenu(event.key === 'ArrowUp');
101
+ });
102
+ menu.addEventListener('toggle', event => {
103
+ if (event.newState !== 'closed') return;
104
+ moreButton.setAttribute('aria-expanded', 'false');
105
+ menuTracking?.abort();
106
+ });
107
+ menu.addEventListener('keydown', event => {
108
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenu(true); return; }
109
+ if (event.key === 'Tab') { event.preventDefault(); event.stopPropagation(); closeMenu(true); return; }
110
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
111
+ event.preventDefault();
112
+ const items = [...menu.querySelectorAll('[role="menuitem"]')];
113
+ const index = items.indexOf(document.activeElement);
114
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
115
+ items[next]?.focus();
116
+ });
117
+ menu.addEventListener('click', async event => {
118
+ const item = event.target instanceof Element ? event.target.closest('[role="menuitem"]') : null;
119
+ if (!item) return;
120
+ closeMenu(true);
121
+ if (item.dataset.share !== 'native') return;
122
+ try { await navigator.share(sharedPost); }
123
+ catch (error) {
124
+ if (error.name !== 'AbortError' && dialog.open) dialog.querySelector('.post-share-status').textContent = 'Device sharing is unavailable. Try another option or copy the link.';
125
+ }
126
+ });
127
+ const summaryButton = dialog.querySelector('[data-share="summarize"]');
128
+ summaryButton.setAttribute('aria-expanded', 'false');
129
+ summaryButton.setAttribute('aria-controls', 'post-share-summaries');
130
+ summaryButton.addEventListener('click', () => {
131
+ const section = dialog.querySelector('.post-share-summaries');
132
+ section.hidden = !section.hidden;
133
+ summaryButton.setAttribute('aria-expanded', String(!section.hidden));
134
+ if (!section.hidden) section.scrollIntoView({ block: 'nearest', behavior: 'instant' });
135
+ });
136
+ dialog.querySelector('.post-share-close').addEventListener('click', () => dialog.close());
137
+ dialog.addEventListener('keydown', event => {
138
+ if (event.key !== 'Tab') return;
139
+ const controls = [...dialog.querySelectorAll('button, a[href], input')].filter(node => !node.disabled && node.getClientRects().length);
140
+ const first = controls[0];
141
+ const last = controls[controls.length - 1];
142
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
143
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
144
+ });
145
+ let backdropPress = false;
146
+ dialog.addEventListener('pointerdown', event => { backdropPress = event.target === dialog; });
147
+ dialog.addEventListener('click', event => {
148
+ if (event.target !== dialog || !backdropPress) return;
149
+ const bounds = dialog.getBoundingClientRect();
150
+ if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close();
151
+ });
152
+ dialog.addEventListener('close', () => {
153
+ closeMenu();
154
+ copyGeneration++;
155
+ trigger?.setAttribute('aria-expanded', 'false');
156
+ if (trigger?.isConnected) trigger.focus({ preventScroll: true });
157
+ });
158
+ dialog.querySelector('.post-share-image').addEventListener('error', event => { event.target.hidden = true; });
159
+ dialog.querySelector('[data-share="copy"]').addEventListener('click', async () => {
160
+ const generation = ++copyGeneration;
161
+ const url = dialog.querySelector('#post-share-url').value;
162
+ try {
163
+ await navigator.clipboard.writeText(url);
164
+ if (!dialog.open || generation !== copyGeneration) return;
165
+ dialog.querySelector('[data-share="copy"] [data-label]').textContent = 'Copied!';
166
+ dialog.querySelector('.post-share-status').textContent = 'Link copied to clipboard.';
167
+ dialog.querySelector('.post-share-copy-fallback').hidden = true;
168
+ } catch {
169
+ if (!dialog.open || generation !== copyGeneration) return;
170
+ dialog.querySelector('.post-share-status').textContent = 'Select and copy the link below.';
171
+ dialog.querySelector('.post-share-copy-fallback').hidden = false;
172
+ const field = dialog.querySelector('#post-share-url');
173
+ field.focus(); field.select();
174
+ }
175
+ });
176
+ document.body.append(dialog);
177
+ };
178
+ document.addEventListener('click', event => {
179
+ const button = event.target instanceof Element ? event.target.closest('.post-share-button') : null;
180
+ if (!button) return;
181
+ if (!dialog) createDialog();
182
+ if (dialog.open) return;
183
+ trigger = button;
184
+ copyGeneration++;
185
+ const url = new URL(httpUrl(document.querySelector('link[rel="canonical"]')?.href || location.href));
186
+ url.search = ''; url.hash = '';
187
+ const title = meta('og:title') || document.title;
188
+ const description = meta('og:description');
189
+ const previewUrl = new URL(httpUrl(meta('og:url')) || url.href);
190
+ dialog.querySelector('.post-share-domain').textContent = previewUrl.hostname;
191
+ dialog.querySelector('.post-share-preview h3').textContent = title;
192
+ dialog.querySelector('.post-share-description').textContent = description;
193
+ const image = dialog.querySelector('.post-share-image');
194
+ const imageUrl = httpUrl(meta('og:image'));
195
+ image.alt = meta('og:image:alt');
196
+ image.hidden = !imageUrl;
197
+ if (imageUrl) image.src = imageUrl; else image.removeAttribute('src');
198
+ const x = new URL('https://x.com/intent/post');
199
+ x.searchParams.set('text', title); x.searchParams.set('url', url.href);
200
+ external(dialog.querySelector('[data-share="x"]'), x.href);
201
+ dialog.querySelector('[data-share="email"]').href = 'mailto:?subject=' + encodeURIComponent(title) + '&body=' + encodeURIComponent(title + '\\n\\n' + url.href);
202
+ sharedPost = { title, url: url.href };
203
+ const menu = dialog.querySelector('.post-share-menu');
204
+ menu.replaceChildren();
205
+ for (const [name, base, parameters] of [
206
+ ['Facebook', 'https://www.facebook.com/sharer/sharer.php', { u: url.href }],
207
+ ['LinkedIn', 'https://www.linkedin.com/sharing/share-offsite/', { url: url.href }],
208
+ ['WhatsApp', 'https://wa.me/', { text: title + '\\n' + url.href }],
209
+ ['Telegram', 'https://t.me/share/url', { url: url.href, text: title }],
210
+ ['Reddit', 'https://www.reddit.com/submit', { url: url.href, title }],
211
+ ['Bluesky', 'https://bsky.app/intent/compose', { text: title + '\\n' + url.href }],
212
+ ['Threads', 'https://www.threads.net/intent/post', { text: title + '\\n' + url.href }],
213
+ ]) {
214
+ const href = new URL(base);
215
+ for (const [key, value] of Object.entries(parameters)) href.searchParams.set(key, value);
216
+ const link = document.createElement('a');
217
+ link.className = 'post-share-menu-item';
218
+ link.setAttribute('role', 'menuitem');
219
+ link.tabIndex = -1;
220
+ link.textContent = name;
221
+ link.insertAdjacentHTML('beforeend', icons.arrow);
222
+ external(link, href.href);
223
+ menu.append(link);
224
+ }
225
+ if (typeof navigator.share === 'function') {
226
+ const native = document.createElement('button');
227
+ native.type = 'button';
228
+ native.className = 'post-share-menu-item';
229
+ native.dataset.share = 'native';
230
+ native.setAttribute('role', 'menuitem');
231
+ native.tabIndex = -1;
232
+ native.textContent = 'Share via…';
233
+ native.insertAdjacentHTML('beforeend', icons.more);
234
+ menu.append(native);
235
+ }
236
+ const prompt = 'Read and summarize this article: ' + url.href + '\\n\\nTitle: ' + title + '\\n\\nGive a concise overview, the key takeaways, and any important caveats. Link to the original article. If you cannot access the article, say so and ask me to paste the text; do not guess its contents.';
237
+ const providers = dialog.querySelector('.post-share-providers');
238
+ providers.replaceChildren();
239
+ for (const [name, base, parameter] of [['ChatGPT', 'https://chatgpt.com/', 'q'], ['Claude', 'https://claude.ai/new', 'q'], ['Perplexity', 'https://www.perplexity.ai/search', 'q']]) {
240
+ const href = new URL(base); href.searchParams.set(parameter, prompt);
241
+ const link = document.createElement('a');
242
+ link.className = 'post-share-provider';
243
+ link.textContent = name;
244
+ link.insertAdjacentHTML('beforeend', icons.arrow);
245
+ link.setAttribute('aria-label', 'Summarize with ' + name + ' (opens in a new tab)');
246
+ external(link, href.href); providers.append(link);
247
+ }
248
+ dialog.querySelector('#post-share-url').value = url.href;
249
+ dialog.querySelector('.post-share-status').textContent = '';
250
+ dialog.querySelector('.post-share-copy-fallback').hidden = true;
251
+ dialog.querySelector('[data-share="copy"] [data-label]').textContent = 'Copy link';
252
+ dialog.querySelector('.post-share-summaries').hidden = true;
253
+ dialog.querySelector('[data-share="summarize"]').setAttribute('aria-expanded', 'false');
254
+ trigger.setAttribute('aria-controls', dialog.id);
255
+ trigger.setAttribute('aria-expanded', 'true');
256
+ dialog.showModal();
257
+ });
258
+ ensureTrigger();
259
+ document.addEventListener('lettercamp:post-updated', () => {
260
+ if (dialog?.open) dialog.close();
261
+ ensureTrigger();
262
+ });
263
+ })();
264
+ `;
@@ -0,0 +1,69 @@
1
+ // A string lets both the static renderer and preview inject the same standalone script.
2
+ export const postTocScript = `
3
+ (() => {
4
+ let cleanup = () => {};
5
+ const mount = () => {
6
+ cleanup();
7
+ const sidebar = document.querySelector('.article-sidebar');
8
+ const nav = sidebar?.querySelector('.article-toc');
9
+ if (!sidebar || !nav) return;
10
+ sidebar.querySelector('.toc-trigger')?.remove();
11
+ const links = [...nav.querySelectorAll('a[href^="#"]')];
12
+ if (!links.length) return;
13
+ const controller = new AbortController();
14
+ const signal = controller.signal;
15
+ const trigger = document.createElement('button');
16
+ trigger.type = 'button';
17
+ trigger.className = 'toc-trigger';
18
+ trigger.setAttribute('aria-label', 'Table of contents');
19
+ trigger.setAttribute('aria-controls', 'post-contents');
20
+ trigger.setAttribute('aria-expanded', 'false');
21
+ nav.id = 'post-contents';
22
+ nav.querySelector('h2').textContent = 'Contents';
23
+ const markers = links.map(() => {
24
+ const marker = document.createElement('span');
25
+ marker.setAttribute('aria-hidden', 'true');
26
+ trigger.append(marker);
27
+ return marker;
28
+ });
29
+ sidebar.prepend(trigger);
30
+ sidebar.setAttribute('data-floating-toc', '');
31
+ const setOpen = open => {
32
+ sidebar.toggleAttribute('data-toc-open', open);
33
+ trigger.setAttribute('aria-expanded', String(open));
34
+ };
35
+ sidebar.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') setOpen(true); }, { signal });
36
+ sidebar.addEventListener('pointerleave', () => { if (!sidebar.contains(document.activeElement)) setOpen(false); }, { signal });
37
+ sidebar.addEventListener('focusin', () => setOpen(true), { signal });
38
+ sidebar.addEventListener('focusout', event => { if (!sidebar.contains(event.relatedTarget)) setOpen(false); }, { signal });
39
+ trigger.addEventListener('click', () => setOpen(true), { signal });
40
+ document.addEventListener('pointerdown', event => { if (!sidebar.contains(event.target)) setOpen(false); }, { signal });
41
+ sidebar.addEventListener('keydown', event => {
42
+ if (event.key !== 'Escape') return;
43
+ event.preventDefault(); trigger.focus(); setOpen(false);
44
+ }, { signal });
45
+ nav.addEventListener('click', event => {
46
+ if (event.target.closest('a')) { trigger.focus(); setOpen(false); }
47
+ }, { signal });
48
+ const headings = links.map(link => document.getElementById(decodeURIComponent(link.hash.slice(1))));
49
+ let frame = 0;
50
+ const update = () => {
51
+ frame = 0;
52
+ let active = -1;
53
+ headings.forEach((heading, index) => { if (heading && heading.getBoundingClientRect().top <= 120) active = index; });
54
+ links.forEach((link, index) => {
55
+ if (index === active) link.setAttribute('aria-current', 'location');
56
+ else link.removeAttribute('aria-current');
57
+ markers[index].toggleAttribute('data-active', index === active);
58
+ });
59
+ };
60
+ const schedule = () => { if (!frame) frame = requestAnimationFrame(update); };
61
+ window.addEventListener('scroll', schedule, { passive: true, signal });
62
+ window.addEventListener('resize', schedule, { passive: true, signal });
63
+ update();
64
+ cleanup = () => { controller.abort(); cancelAnimationFrame(frame); setOpen(false); };
65
+ };
66
+ document.addEventListener('lettercamp:post-updated', mount);
67
+ mount();
68
+ })();
69
+ `;
@@ -0,0 +1,40 @@
1
+ import { sitePath } from './urls.js';
2
+ import { pages, contentPrefix } from '../content.js';
3
+ import { categoryRoutes } from './category-routes.js';
4
+
5
+ export function getCategories() {
6
+ return categoryRoutes(getPosts().map(post => post.category)).map(category => ({ ...category, href: sitePath(category.href) }));
7
+ }
8
+
9
+ export function pageSlug(filename) {
10
+ return filename.slice(contentPrefix.length).replace(/\.(md|mdx)$/, '');
11
+ }
12
+
13
+ export function postDate(value, language = 'en', timeZone = 'UTC') {
14
+ if (!value) return { iso: '', label: '', timestamp: -Infinity };
15
+ const date = value instanceof Date ? value : new Date(String(value));
16
+ if (Number.isNaN(date.valueOf())) return { iso: '', label: '', timestamp: -Infinity };
17
+ // A calendar date has no time zone; shifting midnight changes its intended day.
18
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(String(value));
19
+ return {
20
+ iso: dateOnly ? String(value) : date.toISOString(),
21
+ label: new Intl.DateTimeFormat(language, { month: 'short', day: 'numeric', year: 'numeric', timeZone: dateOnly ? 'UTC' : timeZone }).format(date),
22
+ timestamp: date.valueOf(),
23
+ };
24
+ }
25
+
26
+ export function getPosts(language = 'en', timeZone = 'UTC') {
27
+ return Object.entries(pages)
28
+ .map(([filename, page]) => {
29
+ const frontmatter = page.frontmatter ?? {};
30
+ const slug = pageSlug(filename);
31
+ return {
32
+ ...frontmatter,
33
+ title: frontmatter.title ?? slug,
34
+ category: typeof frontmatter.category === 'string' ? frontmatter.category.trim() : '',
35
+ href: sitePath(`/${slug.split('/').map(encodeURIComponent).join('/')}/`),
36
+ date: postDate(frontmatter.date, language, timeZone),
37
+ };
38
+ })
39
+ .sort((a, b) => b.date.timestamp - a.date.timestamp || a.href.localeCompare(b.href));
40
+ }
@@ -0,0 +1,14 @@
1
+ // Generated from lettercamp/lib/publication-url.ts. Run pnpm sync:renderer in the app repository.
2
+ // Publication paths are relative to the blog; absolute URLs can link to the parent website.
3
+ export function publicationBasePath(url) {
4
+ return new URL(url).pathname.replace(/\/+$/, "");
5
+ }
6
+ export function publicationPath(path, basePath = "") {
7
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//"))
8
+ return path;
9
+ const base = basePath.replace(/\/+$/, "");
10
+ return base + path;
11
+ }
12
+ export function publicationUrl(path, siteUrl) {
13
+ return new URL(publicationPath(path, publicationBasePath(siteUrl)), siteUrl);
14
+ }
@@ -0,0 +1,12 @@
1
+ // Generated from lettercamp/lib/table-styles.ts. Run pnpm sync:renderer in the app repository.
2
+ // Shared by the editable table, safe preview, and published renderer.
3
+ export const tableStyles = `
4
+ [data-lettercamp-table] { position: relative; margin-block: 24px 32px; color: #303034; font: inherit; }
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; }
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
+ [data-lettercamp-table] [data-table-cell-height] [data-table-content] { height: var(--table-cell-height); min-height: 0; overflow: auto; }
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; }
11
+ [data-lettercamp-table] [data-table-content] a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
12
+ `;
@@ -0,0 +1,3 @@
1
+ import { publicationPath } from './publication-url.js';
2
+ export const basePath = import.meta.env.BASE_URL.replace(/\/+$/, '');
3
+ export const sitePath = path => publicationPath(path, basePath);
@@ -1,41 +1,66 @@
1
1
  ---
2
2
  import SiteLayout from '../layouts/SiteLayout.astro';
3
+ import AuthorByline from '../components/AuthorByline.astro';
4
+ import { pageSlug, postDate, getCategories } from '../lib/posts.js';
3
5
  import site from '../site.js';
4
- import { pages, contentPrefix } from '../content.js';
6
+ import { readContentWidthEm } from '../lib/post-layout';
7
+ import { pages } from '../content.js';
8
+ import { postShareButton } from '../lib/post-share';
5
9
 
6
10
  export function getStaticPaths() {
7
11
  return Object.entries(pages).map(([filename, page]) => {
8
- const slug = filename.slice(contentPrefix.length).replace(/\.(md|mdx)$/, '')
9
- .replace(/(^|\/)index$/, '');
10
- return { params: { slug: slug || undefined }, props: { page } };
12
+ const slug = pageSlug(filename);
13
+ return { params: { slug }, props: { page } };
11
14
  });
12
15
  }
13
16
 
14
17
  const { page } = Astro.props;
15
18
  const { Content, frontmatter = {} } = page;
16
- const home = frontmatter.layout === 'home' || Astro.url.pathname === '/';
17
- const author = frontmatter.author ?? site.author;
18
- const date = frontmatter.date ? new Date(`${String(frontmatter.date).slice(0, 10)}T12:00:00Z`) : null;
19
- const dateLabel = date && !Number.isNaN(date.valueOf())
20
- ? new Intl.DateTimeFormat(site.language ?? 'en', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(date)
21
- : null;
19
+ const coverImage = frontmatter.image ?? frontmatter.ogImage;
20
+ const coverImageWidth = Number(frontmatter.coverImageWidth ?? (frontmatter.image ? frontmatter.imageWidth : undefined));
21
+ const coverImageHeight = Number(frontmatter.coverImageHeight ?? (frontmatter.image ? frontmatter.imageHeight : undefined));
22
+ const coverSizeKnown = Number.isFinite(coverImageWidth) && coverImageWidth > 0 && Number.isFinite(coverImageHeight) && coverImageHeight > 0;
23
+ const headings = (page.getHeadings?.() ?? []).filter(({ depth }) => depth === 2 || depth === 3);
24
+ const category = typeof frontmatter.category === 'string' ? frontmatter.category.trim() : '';
25
+ const categoryHref = getCategories().find(item => item.name === category)?.href;
26
+ const date = postDate(frontmatter.date, site.language, site.timeZone);
27
+ const postLayout = site.post ?? {};
28
+ const coverWidthMode = ['regular', 'wide', 'full'].includes(frontmatter.coverWidthMode) ? frontmatter.coverWidthMode : 'wide';
29
+ const coverWidth = /^[0-9]+$/.test(String(frontmatter.coverWidth)) && Number(frontmatter.coverWidth) > 0 ? `${frontmatter.coverWidth}px` : undefined;
22
30
  ---
23
31
 
24
- <SiteLayout site={site} page={frontmatter}>
25
- <article class:list={['publication', { 'publication-home': home }]}>
26
- <header class="article-header">
27
- {!home && <a class="back-link" href="/">← All posts</a>}
28
- <p class="eyebrow">{home ? 'News, ideas & notes' : site.name}</p>
29
- <h1>{frontmatter.title ?? site.name}</h1>
30
- {frontmatter.description && <p class="description">{frontmatter.description}</p>}
31
- {!home && (author || dateLabel) && <p class="byline">
32
- {author && <span>{author}</span>}
33
- {dateLabel && <time datetime={String(frontmatter.date).slice(0, 10)}>{dateLabel}</time>}
34
- </p>}
32
+ <SiteLayout site={site} page={frontmatter} article>
33
+ <article class="publication publication-post" style={{ '--post-content-width': `calc(var(--text-base) * ${readContentWidthEm(postLayout)})` }} data-post-hero={postLayout.heroLayout ?? 'stacked'}
34
+ data-reading-minutes={frontmatter.readingMinutes}
35
+ data-post-cover={postLayout.showCover === false ? 'hidden' : 'visible'} data-post-toc={postLayout.showTableOfContents === false ? 'hidden' : 'visible'}>
36
+ <header class:list={['article-header', 'article-hero', { 'article-hero-with-image': coverImage }]}>
37
+ <div class="article-intro">
38
+ {category && <p class="article-category">
39
+ <a href={categoryHref}>{category}</a>
40
+ </p>}
41
+ <h1>{frontmatter.title ?? site.name}</h1>
42
+ {frontmatter.description && <p class="description">{frontmatter.description}</p>}
43
+ <div class="byline">
44
+ <AuthorByline post={frontmatter} site={site} date={date} />
45
+ <Fragment set:html={postShareButton} />
46
+ </div>
47
+ </div>
48
+ {coverImage && <figure class="article-hero-image" data-image-width={coverWidthMode} style={{ '--cover-image-width': coverWidth }}>
49
+ <img src={coverImage} alt={frontmatter.imageAlt ?? ''} width={coverSizeKnown ? coverImageWidth : 1600} height={coverSizeKnown ? coverImageHeight : 900} fetchpriority="high" />
50
+ </figure>}
35
51
  </header>
36
- {!home && frontmatter.image && <figure class="cover">
37
- <img src={frontmatter.image} alt={frontmatter.imageAlt ?? ''} width="1200" height="630" />
38
- </figure>}
39
- <div class="prose"><Content /></div>
52
+ <div class:list={['article-body', { 'article-body-with-toc': headings.length > 0 }]}>
53
+ {headings.length > 0 && <aside class="article-sidebar">
54
+ <nav class="article-toc" aria-label="Table of contents">
55
+ <h2>On this page</h2>
56
+ <ol>
57
+ {headings.map(({ depth, slug, text }) => <li class:list={{ 'toc-subheading': depth === 3 }}>
58
+ <a href={`#${slug}`} title={text}><span class="toc-label">{text}</span></a>
59
+ </li>)}
60
+ </ol>
61
+ </nav>
62
+ </aside>}
63
+ <div class="prose"><Content /></div>
64
+ </div>
40
65
  </article>
41
66
  </SiteLayout>
@@ -0,0 +1,16 @@
1
+ ---
2
+ import SiteLayout from '../../layouts/SiteLayout.astro';
3
+ import BlogHome from '../../components/BlogHome.astro';
4
+ import { getCategories } from '../../lib/posts.js';
5
+ import site from '../../site.js';
6
+
7
+ export function getStaticPaths() {
8
+ return getCategories().map(category => ({ params: { tag: category.tag }, props: { category: category.name } }));
9
+ }
10
+
11
+ const { category } = Astro.props;
12
+ ---
13
+
14
+ <SiteLayout site={site} page={{ title: category }}>
15
+ <BlogHome site={site} category={category} />
16
+ </SiteLayout>
@@ -0,0 +1,4 @@
1
+ ---
2
+ import { sitePath } from '../../lib/urls.js';
3
+ return Astro.redirect(sitePath('/'), 308);
4
+ ---
@@ -0,0 +1,10 @@
1
+ ---
2
+ import SiteLayout from '../layouts/SiteLayout.astro';
3
+ import BlogHome from '../components/BlogHome.astro';
4
+ import site from '../site.js';
5
+ const title = site.home.title ?? `${site.name} Blog`;
6
+ ---
7
+
8
+ <SiteLayout site={site} page={{ title }}>
9
+ <BlogHome site={site} />
10
+ </SiteLayout>
@@ -0,0 +1,57 @@
1
+ .byline:has(.post-share-button) { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; }
2
+ .byline:has(.post-share-button) .post-byline { grid-column: 1; grid-row: 1; min-width: 0; }
3
+ .byline > .post-share-button { grid-column: 2; grid-row: 1; justify-self: end; }
4
+ .post-share-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; flex-shrink: 0; margin-left: auto; padding: 5px 11px; border: 0; border-radius: 999px; background: #f5f5f5; color: #555; font-size: 13px; line-height: 1.4; }
5
+ .post-share-button:hover { background: #ededed; color: #111; }
6
+ .post-share-button svg { width: 15px; height: 15px; }
7
+ html:has(.post-share-dialog[open]) { overflow: hidden; }
8
+ .post-share-dialog { position: fixed; width: min(380px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); margin: auto; padding: 16px; overflow: auto; overscroll-behavior: contain; border: 0; border-radius: 14px; background: var(--site-background, #fff); color: #222; box-shadow: 0 20px 80px #0003; font: 13px/1.5 var(--font-family); }
9
+ .post-share-dialog::backdrop { background: #0006; }
10
+ .post-share-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
11
+ .post-share-heading h2 { margin: 0; font: 600 15px/1.4 var(--font-family); letter-spacing: -.3px; }
12
+ .post-share-close { display: grid; place-items: center; flex-shrink: 0; width: 28px; height: 28px; margin: -4px -4px -4px 0; padding: 0; border: 0; background: transparent; color: #777; }
13
+ .post-share-close:hover { color: #111; }
14
+ .post-share-close svg { width: 19px; height: 19px; }
15
+ .post-share-preview { overflow: hidden; border: 1px solid #e5e5e5; border-radius: 8px; background: #f7f7f7; color: #222; font-family: Arial, Helvetica, sans-serif; }
16
+ .post-share-image { display: block; width: 100%; height: auto; aspect-ratio: 1200 / 630; object-fit: cover; }
17
+ .post-share-preview-copy { padding: 9px 10px; }
18
+ .post-share-domain { margin: 0 0 4px; color: #777; font-size: 10px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
19
+ .post-share-preview h3 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin: 0; font: 600 14px/1.3 Arial, Helvetica, sans-serif; letter-spacing: 0; text-wrap: auto; }
20
+ .post-share-description { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin: 4px 0 0; color: #666; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
21
+ .post-share-description:empty { display: none; }
22
+ .post-share-actions { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-top: 22px; }
23
+ .post-share-action { display: flex; flex-direction: column; align-items: center; gap: 5px; min-width: 0; padding: 0; border: 0; background: transparent; color: #555; font-size: 11px; line-height: 1.4; text-decoration: none; }
24
+ .post-share-action-icon { display: grid; place-items: center; width: 100%; height: 40px; border-radius: 10px; background: #00000005; }
25
+ .post-share-action:hover, .post-share-action[aria-expanded='true'] { color: #111; }
26
+ .post-share-action:hover .post-share-action-icon, .post-share-action[aria-expanded='true'] .post-share-action-icon { background: #0000000a; }
27
+ .post-share-action svg { width: 18px; height: 18px; }
28
+ .post-share-menu { position: fixed; inset: auto; width: 184px; max-width: calc(100vw - 24px); max-height: calc(100dvh - 24px); margin: 0; padding: 6px; overflow: auto; overscroll-behavior: contain; border: 1px solid #eaeaea; border-radius: 10px; background: #fafafa; color: #444; box-shadow: 0 6px 24px #0002; font: 12px/1.4 var(--font-family); }
29
+ .post-share-menu::backdrop { background: transparent; }
30
+ .post-share-menu-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 7px 9px; border: 0; border-radius: 6px; background: transparent; color: inherit; text-align: left; font: inherit; text-decoration: none; }
31
+ .post-share-menu-item:is(:hover, :focus-visible) { background: #efefef; color: #111; outline: none; }
32
+ .post-share-menu-item svg { width: 15px; height: 15px; color: #888; }
33
+ .post-share-menu-item[data-share='native'] { margin-top: 4px; border-top: 1px solid #e8e8e8; border-radius: 0; }
34
+ .post-share-summaries { margin-top: 14px; padding-top: 12px; border-top: 1px solid #0000000d; }
35
+ .post-share-summaries h3 { margin: 0; font: 600 13px/1.5 var(--font-family); }
36
+ .post-share-summaries > p { margin: 3px 0 9px; color: #777; font-size: 12px; }
37
+ .post-share-providers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
38
+ .post-share-provider { display: flex; align-items: center; justify-content: space-between; gap: 5px; padding: 7px 9px; border-radius: 7px; background: #00000005; font-size: 12px; text-decoration: none; }
39
+ .post-share-provider:hover { background: #0000000a; }
40
+ .post-share-provider svg { width: 13px; height: 13px; flex-shrink: 0; color: #888; }
41
+ .post-share-status { margin: 12px 0 0; color: #666; font-size: 12px; text-align: center; }
42
+ .post-share-status:empty { display: none; }
43
+ .post-share-copy-fallback { margin-top: 12px; }
44
+ .post-share-copy-fallback label { display: block; margin-bottom: 4px; color: #666; font-size: 12px; }
45
+ .post-share-copy-fallback input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid #ddd; border-radius: 8px; background: #fff; color: #333; font: inherit; }
46
+ @media (max-width: 640px) {
47
+ .byline:has(.post-share-button) .post-byline { flex-wrap: wrap; gap: 6px 12px; overflow: visible; white-space: normal; }
48
+ .byline:has(.post-share-button) .post-authors { max-width: 100%; white-space: normal; }
49
+ }
50
+ @media (max-width: 400px) {
51
+ .post-share-provider { padding: 7px; font-size: 11px; }
52
+ .byline:has(.post-share-button) { column-gap: 10px; }
53
+ }
54
+ @media (prefers-reduced-motion: no-preference) {
55
+ .post-share-dialog[open] { animation: post-share-enter 140ms ease-out; }
56
+ @keyframes post-share-enter { from { opacity: 0; transform: translateY(6px) scale(.98); } to { opacity: 1; transform: none; } }
57
+ }