@lettercamp/cli 0.1.7 → 0.1.9
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/account.js +20 -8
- package/dist/build.js +22 -5
- package/dist/cli.js +15 -6
- package/dist/code-block.js +50 -0
- package/dist/code-detection.js +24 -0
- package/dist/code-highlight.js +54 -0
- package/dist/component-blocks.js +32 -0
- package/dist/custom-layout.js +32 -0
- package/dist/deploy.js +131 -0
- package/dist/dev.js +28 -10
- package/dist/domain.js +190 -0
- package/dist/hosting.js +132 -0
- package/dist/lettercamp.js +1 -4
- package/dist/media-dimensions.js +154 -0
- package/dist/new.js +1 -1
- package/dist/preview.js +1 -1
- package/dist/prompts.js +31 -0
- package/dist/publication-links.js +23 -0
- package/dist/react.js +15 -0
- package/dist/upload.js +40 -10
- package/dist/workspace.js +5 -3
- package/package.json +11 -2
- package/renderer/src/components/AuthorByline.astro +20 -11
- package/renderer/src/components/BlogHome.astro +14 -57
- package/renderer/src/components/Footer.astro +12 -9
- package/renderer/src/components/Header.astro +38 -0
- package/renderer/src/components/MobileMenuIcons.astro +7 -0
- package/renderer/src/components/PostCard.astro +12 -5
- package/renderer/src/components/SocialLink.astro +4 -4
- package/renderer/src/layouts/SiteLayout.astro +70 -51
- package/renderer/src/lib/analytics.ts +21 -0
- package/renderer/src/lib/author-dialog.ts +102 -0
- package/renderer/src/lib/authors.js +18 -0
- package/renderer/src/lib/block-widths.ts +35 -0
- package/renderer/src/lib/branding.js +15 -0
- package/renderer/src/lib/button.js +10 -0
- package/renderer/src/lib/category-routes.js +19 -0
- package/renderer/src/lib/code-block.ts +46 -0
- package/renderer/src/lib/font-fallbacks.js +196 -0
- package/renderer/src/lib/fonts.js +48 -0
- package/renderer/src/lib/home-layout.ts +5 -0
- package/renderer/src/lib/image-radius.ts +5 -0
- package/renderer/src/lib/image-rounding.ts +28 -0
- package/renderer/src/lib/image-widths.ts +13 -0
- package/renderer/src/lib/metadata.js +47 -0
- package/renderer/src/lib/mobile-navigation.ts +76 -0
- package/renderer/src/lib/post-layout.ts +6 -0
- package/renderer/src/lib/post-share.ts +264 -0
- package/renderer/src/lib/post-toc.ts +69 -0
- package/renderer/src/lib/posts.js +14 -6
- package/renderer/src/lib/publication-url.js +14 -0
- package/renderer/src/lib/table-styles.ts +12 -0
- package/renderer/src/lib/urls.js +3 -0
- package/renderer/src/pages/[...slug].astro +21 -11
- package/renderer/src/pages/category/[tag].astro +16 -0
- package/renderer/src/pages/category/index.astro +4 -0
- package/renderer/src/pages/index.astro +1 -1
- package/renderer/src/styles/post-share.css +57 -0
- package/renderer/src/styles/site.css +261 -91
- package/dist/public-assets.js +0 -29
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Delegated events also handle header controls rebuilt by the design preview.
|
|
2
|
+
export const mobileNavigationScript = `
|
|
3
|
+
(() => {
|
|
4
|
+
const mobile = matchMedia('(max-width: 600px)');
|
|
5
|
+
let active;
|
|
6
|
+
let restore;
|
|
7
|
+
const close = (focus = false) => {
|
|
8
|
+
if (!active) return;
|
|
9
|
+
const menu = active;
|
|
10
|
+
active = null;
|
|
11
|
+
menu.open = false;
|
|
12
|
+
const toggle = menu.querySelector('summary');
|
|
13
|
+
toggle.setAttribute('aria-label', 'Open menu');
|
|
14
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
15
|
+
restore?.();
|
|
16
|
+
restore = null;
|
|
17
|
+
if (focus && toggle.isConnected) toggle.focus({ preventScroll: true });
|
|
18
|
+
};
|
|
19
|
+
const position = () => {
|
|
20
|
+
if (!active) return;
|
|
21
|
+
const header = active.closest('.site-header');
|
|
22
|
+
header.style.setProperty('--mobile-menu-top', Math.max(0, header.getBoundingClientRect().bottom) + 'px');
|
|
23
|
+
};
|
|
24
|
+
document.addEventListener('toggle', event => {
|
|
25
|
+
const menu = event.target;
|
|
26
|
+
if (!(menu instanceof HTMLDetailsElement) || !menu.matches('.mobile-navigation')) return;
|
|
27
|
+
if (!menu.open) { if (active === menu) close(); return; }
|
|
28
|
+
if (!mobile.matches) { menu.open = false; return; }
|
|
29
|
+
if (active === menu) return;
|
|
30
|
+
close();
|
|
31
|
+
active = menu;
|
|
32
|
+
const header = menu.closest('.site-header');
|
|
33
|
+
const toggle = menu.querySelector('summary');
|
|
34
|
+
toggle.setAttribute('aria-label', 'Close menu');
|
|
35
|
+
toggle.setAttribute('aria-expanded', 'true');
|
|
36
|
+
const body = document.body;
|
|
37
|
+
const root = document.documentElement;
|
|
38
|
+
const overflow = body.style.overflow;
|
|
39
|
+
const rootOverflow = root.style.overflow;
|
|
40
|
+
const padding = body.style.paddingRight;
|
|
41
|
+
const scrollbar = innerWidth - root.clientWidth;
|
|
42
|
+
if (scrollbar > 0) body.style.paddingRight = (parseFloat(getComputedStyle(body).paddingRight) + scrollbar) + 'px';
|
|
43
|
+
body.style.overflow = root.style.overflow = 'hidden';
|
|
44
|
+
const background = [...body.children].filter(node => node instanceof HTMLElement && !node.contains(header) && !['SCRIPT', 'STYLE'].includes(node.tagName));
|
|
45
|
+
const inert = background.map(node => node.inert);
|
|
46
|
+
background.forEach(node => { node.inert = true; });
|
|
47
|
+
restore = () => {
|
|
48
|
+
body.style.overflow = overflow;
|
|
49
|
+
root.style.overflow = rootOverflow;
|
|
50
|
+
body.style.paddingRight = padding;
|
|
51
|
+
background.forEach((node, index) => { node.inert = inert[index]; });
|
|
52
|
+
header.style.removeProperty('--mobile-menu-top');
|
|
53
|
+
};
|
|
54
|
+
position();
|
|
55
|
+
}, true);
|
|
56
|
+
document.addEventListener('keydown', event => {
|
|
57
|
+
if (!active) return;
|
|
58
|
+
if (event.key === 'Escape') { event.preventDefault(); close(true); return; }
|
|
59
|
+
if (event.key !== 'Tab') return;
|
|
60
|
+
const header = active.closest('.site-header');
|
|
61
|
+
const items = [...header.querySelectorAll('a[href], summary, button:not(:disabled)')].filter(node => node.getClientRects().length);
|
|
62
|
+
const first = items[0], last = items[items.length - 1];
|
|
63
|
+
if (event.shiftKey && (document.activeElement === first || !header.contains(document.activeElement))) {
|
|
64
|
+
event.preventDefault(); last?.focus();
|
|
65
|
+
} else if (!event.shiftKey && (document.activeElement === last || !header.contains(document.activeElement))) {
|
|
66
|
+
event.preventDefault(); first?.focus();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
document.addEventListener('click', event => {
|
|
70
|
+
if (active && event.target instanceof Element && event.target.closest('.site-header a[href]')) close();
|
|
71
|
+
}, true);
|
|
72
|
+
document.addEventListener('lettercamp:header-update', () => close());
|
|
73
|
+
window.addEventListener('pagehide', () => close());
|
|
74
|
+
window.addEventListener('resize', () => { if (!mobile.matches) close(); else position(); });
|
|
75
|
+
})();
|
|
76
|
+
`;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/post-layout.ts.
|
|
2
|
+
export function readContentWidthEm(layout: { contentWidthEm?: unknown } = {}) {
|
|
3
|
+
const width = layout.contentWidthEm;
|
|
4
|
+
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
|
|
5
|
+
return 40;
|
|
6
|
+
}
|
|
@@ -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
|
+
`;
|
|
@@ -1,21 +1,29 @@
|
|
|
1
|
+
import { sitePath } from './urls.js';
|
|
1
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
|
+
}
|
|
2
8
|
|
|
3
9
|
export function pageSlug(filename) {
|
|
4
10
|
return filename.slice(contentPrefix.length).replace(/\.(md|mdx)$/, '');
|
|
5
11
|
}
|
|
6
12
|
|
|
7
|
-
export function postDate(value, language = 'en') {
|
|
13
|
+
export function postDate(value, language = 'en', timeZone = 'UTC') {
|
|
8
14
|
if (!value) return { iso: '', label: '', timestamp: -Infinity };
|
|
9
15
|
const date = value instanceof Date ? value : new Date(String(value));
|
|
10
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));
|
|
11
19
|
return {
|
|
12
|
-
iso: date.toISOString()
|
|
13
|
-
label: new Intl.DateTimeFormat(language, { month: '
|
|
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),
|
|
14
22
|
timestamp: date.valueOf(),
|
|
15
23
|
};
|
|
16
24
|
}
|
|
17
25
|
|
|
18
|
-
export function getPosts(language = 'en') {
|
|
26
|
+
export function getPosts(language = 'en', timeZone = 'UTC') {
|
|
19
27
|
return Object.entries(pages)
|
|
20
28
|
.map(([filename, page]) => {
|
|
21
29
|
const frontmatter = page.frontmatter ?? {};
|
|
@@ -24,8 +32,8 @@ export function getPosts(language = 'en') {
|
|
|
24
32
|
...frontmatter,
|
|
25
33
|
title: frontmatter.title ?? slug,
|
|
26
34
|
category: typeof frontmatter.category === 'string' ? frontmatter.category.trim() : '',
|
|
27
|
-
href: `/${slug.split('/').map(encodeURIComponent).join('/')}
|
|
28
|
-
date: postDate(frontmatter.date, language),
|
|
35
|
+
href: sitePath(`/${slug.split('/').map(encodeURIComponent).join('/')}/`),
|
|
36
|
+
date: postDate(frontmatter.date, language, timeZone),
|
|
29
37
|
};
|
|
30
38
|
})
|
|
31
39
|
.sort((a, b) => b.date.timestamp - a.date.timestamp || a.href.localeCompare(b.href));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/publication-url.ts.
|
|
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.
|
|
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
|
+
`;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
import SiteLayout from '../layouts/SiteLayout.astro';
|
|
3
3
|
import AuthorByline from '../components/AuthorByline.astro';
|
|
4
|
-
import { pageSlug, postDate } from '../lib/posts.js';
|
|
4
|
+
import { pageSlug, postDate, getCategories } from '../lib/posts.js';
|
|
5
5
|
import site from '../site.js';
|
|
6
|
+
import { readContentWidthEm } from '../lib/post-layout';
|
|
6
7
|
import { pages } from '../content.js';
|
|
8
|
+
import { postShareButton } from '../lib/post-share';
|
|
7
9
|
|
|
8
10
|
export function getStaticPaths() {
|
|
9
11
|
return Object.entries(pages).map(([filename, page]) => {
|
|
@@ -15,28 +17,36 @@ export function getStaticPaths() {
|
|
|
15
17
|
const { page } = Astro.props;
|
|
16
18
|
const { Content, frontmatter = {} } = page;
|
|
17
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;
|
|
18
23
|
const headings = (page.getHeadings?.() ?? []).filter(({ depth }) => depth === 2 || depth === 3);
|
|
19
24
|
const category = typeof frontmatter.category === 'string' ? frontmatter.category.trim() : '';
|
|
20
|
-
const
|
|
21
|
-
const
|
|
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
32
|
<SiteLayout site={site} page={frontmatter} article>
|
|
25
|
-
<article class="publication publication-post"
|
|
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}
|
|
26
35
|
data-post-cover={postLayout.showCover === false ? 'hidden' : 'visible'} data-post-toc={postLayout.showTableOfContents === false ? 'hidden' : 'visible'}>
|
|
27
|
-
<a class="back-link" href="/">← All posts</a>
|
|
28
36
|
<header class:list={['article-header', 'article-hero', { 'article-hero-with-image': coverImage }]}>
|
|
29
37
|
<div class="article-intro">
|
|
30
|
-
|
|
38
|
+
{category && <p class="article-category">
|
|
39
|
+
<a href={categoryHref}>{category}</a>
|
|
40
|
+
</p>}
|
|
31
41
|
<h1>{frontmatter.title ?? site.name}</h1>
|
|
32
42
|
{frontmatter.description && <p class="description">{frontmatter.description}</p>}
|
|
33
43
|
<div class="byline">
|
|
34
|
-
|
|
35
|
-
<
|
|
44
|
+
<AuthorByline post={frontmatter} site={site} date={date} />
|
|
45
|
+
<Fragment set:html={postShareButton} />
|
|
36
46
|
</div>
|
|
37
47
|
</div>
|
|
38
|
-
{coverImage && <figure class="article-hero-image">
|
|
39
|
-
<img src={coverImage} alt={frontmatter.imageAlt ?? ''} width=
|
|
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" />
|
|
40
50
|
</figure>}
|
|
41
51
|
</header>
|
|
42
52
|
<div class:list={['article-body', { 'article-body-with-toc': headings.length > 0 }]}>
|
|
@@ -45,7 +55,7 @@ const postLayout = site.postLayout ?? {};
|
|
|
45
55
|
<h2>On this page</h2>
|
|
46
56
|
<ol>
|
|
47
57
|
{headings.map(({ depth, slug, text }) => <li class:list={{ 'toc-subheading': depth === 3 }}>
|
|
48
|
-
<a href={`#${slug}`}>{text}</a>
|
|
58
|
+
<a href={`#${slug}`} title={text}><span class="toc-label">{text}</span></a>
|
|
49
59
|
</li>)}
|
|
50
60
|
</ol>
|
|
51
61
|
</nav>
|
|
@@ -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>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import SiteLayout from '../layouts/SiteLayout.astro';
|
|
3
3
|
import BlogHome from '../components/BlogHome.astro';
|
|
4
4
|
import site from '../site.js';
|
|
5
|
-
const title = site.
|
|
5
|
+
const title = site.home.title ?? `${site.name} Blog`;
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
<SiteLayout site={site} page={{ title }}>
|