@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.
- package/README.md +3 -1
- package/dist/account.js +189 -0
- package/dist/build.js +24 -9
- package/dist/cli.js +35 -5
- package/dist/code-block.js +50 -0
- package/dist/code-detection.js +24 -0
- package/dist/code-highlight.js +54 -0
- package/dist/custom-layout.js +32 -0
- package/dist/deploy.js +131 -0
- package/dist/dev.js +52 -15
- package/dist/domain.js +190 -0
- package/dist/hosting.js +132 -0
- package/dist/lettercamp.js +2 -2
- package/dist/media-dimensions.js +154 -0
- package/dist/new.js +1 -1
- package/dist/ports.js +31 -0
- package/dist/preview.js +48 -0
- package/dist/prompts.js +31 -0
- package/dist/publication-links.js +23 -0
- package/dist/react.js +15 -0
- package/dist/reading-time.js +25 -0
- package/dist/upload.js +57 -0
- package/dist/workspace.js +8 -3
- package/package.json +12 -2
- package/renderer/src/components/AuthorByline.astro +26 -0
- package/renderer/src/components/BlogHome.astro +37 -0
- package/renderer/src/components/Footer.astro +35 -0
- package/renderer/src/components/Header.astro +38 -0
- package/renderer/src/components/MobileMenuIcons.astro +7 -0
- package/renderer/src/components/PostCard.astro +25 -0
- package/renderer/src/components/SocialLink.astro +23 -0
- package/renderer/src/layouts/SiteLayout.astro +73 -27
- 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 +30 -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/fonts.js +33 -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 +40 -0
- 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 +50 -25
- package/renderer/src/pages/category/[tag].astro +16 -0
- package/renderer/src/pages/category/index.astro +4 -0
- package/renderer/src/pages/index.astro +10 -0
- package/renderer/src/styles/post-share.css +57 -0
- package/renderer/src/styles/site.css +352 -51
- package/renderer/src/layouts/Content.astro +0 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Delegation also supports bylines replaced by the editor preview bridge.
|
|
2
|
+
export const authorDialogScript = `
|
|
3
|
+
(() => {
|
|
4
|
+
let panel;
|
|
5
|
+
let trigger;
|
|
6
|
+
let tracking;
|
|
7
|
+
let frame;
|
|
8
|
+
const element = (tag, className, text) => {
|
|
9
|
+
const node = document.createElement(tag);
|
|
10
|
+
node.className = className;
|
|
11
|
+
if (text != null) node.textContent = text;
|
|
12
|
+
return node;
|
|
13
|
+
};
|
|
14
|
+
const close = (restoreFocus = false) => {
|
|
15
|
+
if (!panel?.matches(':popover-open')) return;
|
|
16
|
+
panel.hidePopover();
|
|
17
|
+
trigger?.setAttribute('aria-expanded', 'false');
|
|
18
|
+
tracking?.abort();
|
|
19
|
+
cancelAnimationFrame(frame);
|
|
20
|
+
if (restoreFocus && trigger?.isConnected) trigger.focus({ preventScroll: true });
|
|
21
|
+
};
|
|
22
|
+
const position = () => {
|
|
23
|
+
if (!panel?.matches(':popover-open')) return;
|
|
24
|
+
if (!trigger?.isConnected) { close(); return; }
|
|
25
|
+
const anchor = trigger.getBoundingClientRect();
|
|
26
|
+
const viewport = window.visualViewport;
|
|
27
|
+
const left = viewport?.offsetLeft || 0;
|
|
28
|
+
const top = viewport?.offsetTop || 0;
|
|
29
|
+
const width = viewport?.width || innerWidth;
|
|
30
|
+
const height = viewport?.height || innerHeight;
|
|
31
|
+
if (anchor.bottom < top || anchor.top > top + height) { close(); return; }
|
|
32
|
+
const below = top + height - anchor.bottom - 14;
|
|
33
|
+
const above = anchor.top - top - 14;
|
|
34
|
+
const upwards = below < Math.min(panel.scrollHeight, 320) && above > below;
|
|
35
|
+
panel.style.maxHeight = Math.max(40, Math.min(320, upwards ? above : below)) + 'px';
|
|
36
|
+
panel.style.left = Math.max(left + 8, Math.min(anchor.left, left + width - panel.offsetWidth - 8)) + 'px';
|
|
37
|
+
panel.style.top = (upwards ? anchor.top - panel.offsetHeight - 6 : anchor.bottom + 6) + 'px';
|
|
38
|
+
};
|
|
39
|
+
const schedulePosition = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(position); };
|
|
40
|
+
document.addEventListener('click', event => {
|
|
41
|
+
const button = event.target instanceof Element ? event.target.closest('button.author-more') : null;
|
|
42
|
+
if (!button) return;
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
if (trigger === button && panel?.matches(':popover-open')) { close(true); return; }
|
|
45
|
+
close();
|
|
46
|
+
const authors = JSON.parse(button.dataset.authors);
|
|
47
|
+
if (!panel) {
|
|
48
|
+
panel = element('div', 'authors-popover');
|
|
49
|
+
panel.id = 'authors-popover';
|
|
50
|
+
panel.popover = 'auto';
|
|
51
|
+
panel.tabIndex = -1;
|
|
52
|
+
panel.setAttribute('role', 'dialog');
|
|
53
|
+
panel.setAttribute('aria-label', 'Authors');
|
|
54
|
+
panel.append(element('ul', 'authors-popover-list'));
|
|
55
|
+
panel.addEventListener('toggle', event => {
|
|
56
|
+
if (event.newState === 'closed') {
|
|
57
|
+
trigger?.setAttribute('aria-expanded', 'false');
|
|
58
|
+
tracking?.abort();
|
|
59
|
+
cancelAnimationFrame(frame);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
document.body.append(panel);
|
|
63
|
+
}
|
|
64
|
+
const list = panel.querySelector('ul');
|
|
65
|
+
list.replaceChildren();
|
|
66
|
+
for (const author of authors) {
|
|
67
|
+
const row = element('li', 'authors-popover-author');
|
|
68
|
+
const portrait = element('span', 'author-portrait');
|
|
69
|
+
portrait.setAttribute('aria-hidden', 'true');
|
|
70
|
+
if (author.image) {
|
|
71
|
+
const image = element('img', 'author-avatar');
|
|
72
|
+
image.src = author.image; image.alt = ''; image.width = 28; image.height = 28;
|
|
73
|
+
portrait.append(image);
|
|
74
|
+
} else {
|
|
75
|
+
portrait.append(element('span', 'author-initials', author.name.trim().split(/\\s+/).map(part => part[0]).slice(0, 2).join('')));
|
|
76
|
+
}
|
|
77
|
+
row.append(portrait, element('span', '', author.name));
|
|
78
|
+
list.append(row);
|
|
79
|
+
}
|
|
80
|
+
trigger = button;
|
|
81
|
+
trigger.setAttribute('aria-controls', panel.id);
|
|
82
|
+
trigger.setAttribute('popovertarget', panel.id);
|
|
83
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
84
|
+
panel.showPopover();
|
|
85
|
+
position();
|
|
86
|
+
panel.focus({ preventScroll: true });
|
|
87
|
+
tracking = new AbortController();
|
|
88
|
+
const signal = tracking.signal;
|
|
89
|
+
document.addEventListener('keydown', event => {
|
|
90
|
+
if (event.key !== 'Escape') return;
|
|
91
|
+
event.preventDefault(); close(true);
|
|
92
|
+
}, { signal, capture: true });
|
|
93
|
+
document.addEventListener('focusin', event => {
|
|
94
|
+
if (!panel.contains(event.target) && event.target !== trigger) close();
|
|
95
|
+
}, { signal });
|
|
96
|
+
document.addEventListener('scroll', schedulePosition, { signal, capture: true, passive: true });
|
|
97
|
+
window.addEventListener('resize', schedulePosition, { signal, passive: true });
|
|
98
|
+
window.visualViewport?.addEventListener('resize', schedulePosition, { signal, passive: true });
|
|
99
|
+
window.visualViewport?.addEventListener('scroll', schedulePosition, { signal, passive: true });
|
|
100
|
+
});
|
|
101
|
+
})();
|
|
102
|
+
`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/authors.ts. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
export function readAuthors(value) {
|
|
3
|
+
if (!Array.isArray(value))
|
|
4
|
+
return [];
|
|
5
|
+
return value.flatMap(item => item && typeof item.name === "string"
|
|
6
|
+
? [{ name: item.name, ...(typeof item.image === "string" ? { image: item.image } : {}) }] : []);
|
|
7
|
+
}
|
|
8
|
+
// Keep blank names while editing; only bylines omit unfinished author rows.
|
|
9
|
+
export function resolveAuthors(source) {
|
|
10
|
+
return readAuthors(source.authors);
|
|
11
|
+
}
|
|
12
|
+
// Names alone are not identities: two contributors can share a name.
|
|
13
|
+
export function authorKey(author) {
|
|
14
|
+
return JSON.stringify([author.name.trim().toLowerCase(), author.image || ""]);
|
|
15
|
+
}
|
|
16
|
+
export function uniqueAuthors(authors) {
|
|
17
|
+
return [...new Map(authors.filter(author => author.name.trim()).map(author => [authorKey(author), author])).values()];
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/block-widths.ts. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
// One sizing contract for editor surfaces and published body blocks.
|
|
3
|
+
const blocks = ':is(.publication-post .prose > table, [data-lettercamp-table], [data-lettercamp-code][data-code-width], [data-block-width], .publication-post .prose :is(img, video)[data-image-width]:not([width]), .publication-post .prose :is(img, video):is([data-image-width="wide"], [data-image-width="full"]))';
|
|
4
|
+
const wide = ':is([data-table-width="wide"], [data-code-width="wide"], [data-block-width="wide"], [data-image-width="wide"])';
|
|
5
|
+
const full = ':is([data-table-width="full"], [data-code-width="full"], [data-block-width="full"], [data-image-width="full"])';
|
|
6
|
+
|
|
7
|
+
export const blockWidthStyles = `
|
|
8
|
+
.publication-post { container-type: inline-size; }
|
|
9
|
+
${blocks} {
|
|
10
|
+
--block-normal-width: 100%;
|
|
11
|
+
--block-display-width: var(--block-normal-width);
|
|
12
|
+
box-sizing: border-box; width: var(--block-display-width); max-width: var(--block-normal-width); margin-inline: auto;
|
|
13
|
+
}
|
|
14
|
+
${blocks}${wide} {
|
|
15
|
+
--block-display-width: max(var(--block-normal-width), min(calc(var(--block-normal-width) + 128px), var(--block-available-width, 100cqw)));
|
|
16
|
+
max-width: none; margin-inline: calc((100% - var(--block-display-width)) / 2);
|
|
17
|
+
margin-left: calc((100% - var(--block-display-width)) / 2 + min(0px, (var(--block-available-width, 100cqw) - var(--block-display-width)) / 2 - var(--block-column-offset, 0px)));
|
|
18
|
+
}
|
|
19
|
+
${blocks}${full} {
|
|
20
|
+
--block-display-width: max(var(--block-normal-width), var(--block-available-width, 100cqw));
|
|
21
|
+
max-width: none; margin-inline: calc((100% - var(--block-display-width)) / 2);
|
|
22
|
+
margin-left: calc((100% - var(--block-display-width)) / 2 - var(--block-column-offset, 0px));
|
|
23
|
+
}
|
|
24
|
+
/* The two-column TOC layout shifts the reading column 106px from center. */
|
|
25
|
+
@media (min-width: 761px) and (max-width: 1199px) {
|
|
26
|
+
.publication-post[data-post-hero="split"][data-post-toc="visible"] .article-body-with-toc .prose {
|
|
27
|
+
--block-column-offset: 106px;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const defaultSiteBranding = {
|
|
2
|
+
name: 'Lettercamp Blog',
|
|
3
|
+
logo: 'https://lettercamp.com/lettercamp-logo.png?v=7',
|
|
4
|
+
favicon: 'https://lettercamp.com/favicon-96x96.png?v=7',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function withDefaultBranding(source) {
|
|
8
|
+
const branding = Object.fromEntries(Object.entries(defaultSiteBranding).map(([key, fallback]) =>
|
|
9
|
+
[key, typeof source[key] === 'string' && source[key].trim() ? source[key] : fallback]));
|
|
10
|
+
return {
|
|
11
|
+
...source,
|
|
12
|
+
...branding,
|
|
13
|
+
home: { ...source.home, title: source.home?.title ?? (branding.name === defaultSiteBranding.name ? branding.name : `${branding.name} Blog`) },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Keep the color fallback and contrast calculation aligned with the editor preview.
|
|
2
|
+
export function buttonColors(value) {
|
|
3
|
+
const background = typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : '#111111';
|
|
4
|
+
const channels = [1, 3, 5].map(offset => {
|
|
5
|
+
const channel = parseInt(background.slice(offset, offset + 2), 16) / 255;
|
|
6
|
+
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
7
|
+
});
|
|
8
|
+
const luminance = channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
9
|
+
return { background, text: luminance > 0.179 ? '#000000' : '#ffffff' };
|
|
10
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/category-routes.ts. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
export function categoryTag(category) {
|
|
3
|
+
return category.trim().normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "category";
|
|
4
|
+
}
|
|
5
|
+
export function categoryRoutes(categories) {
|
|
6
|
+
const names = [...new Set(categories.map(name => name.trim()).filter(Boolean))];
|
|
7
|
+
const used = new Set();
|
|
8
|
+
const tags = new Map();
|
|
9
|
+
// Assign unique tags independently of the posts' date order.
|
|
10
|
+
for (const name of [...names].sort()) {
|
|
11
|
+
const base = categoryTag(name);
|
|
12
|
+
let tag = base;
|
|
13
|
+
for (let suffix = 2; used.has(tag); suffix++)
|
|
14
|
+
tag = `${base}-${suffix}`;
|
|
15
|
+
used.add(tag);
|
|
16
|
+
tags.set(name, tag);
|
|
17
|
+
}
|
|
18
|
+
return names.map(name => ({ name, tag: tags.get(name), href: `/category/${encodeURIComponent(tags.get(name))}/` }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/code-block.ts. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
export const codeThemes = { classic: "Classic", ocean: "Ocean", forest: "Forest", mono: "Monochrome" } as const;
|
|
3
|
+
export type CodeBlockSettings = { width: "regular" | "wide" | "full"; wrap: boolean; theme: keyof typeof codeThemes; caption: string };
|
|
4
|
+
const settingPattern = /(?:^|\s)lc-(width|wrap|theme|caption)=([^\s]*)/g;
|
|
5
|
+
|
|
6
|
+
export function readCodeSettings(meta = ""): CodeBlockSettings {
|
|
7
|
+
const settings: CodeBlockSettings = { width: "regular", wrap: true, theme: "classic", caption: "" };
|
|
8
|
+
for (const match of meta.matchAll(settingPattern)) {
|
|
9
|
+
const [, key, value] = match;
|
|
10
|
+
if (key === "width" && (value === "regular" || value === "wide" || value === "full")) settings.width = value;
|
|
11
|
+
if (key === "theme" && Object.hasOwn(codeThemes, value)) settings.theme = value as CodeBlockSettings["theme"];
|
|
12
|
+
if (key === "wrap") settings.wrap = value !== "false";
|
|
13
|
+
if (key === "caption") { try { settings.caption = decodeURIComponent(value); } catch { /* Keep malformed source editable. */ } }
|
|
14
|
+
}
|
|
15
|
+
return settings;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeCodeSettings(meta: string, patch: Partial<CodeBlockSettings>) {
|
|
19
|
+
const settings = { ...readCodeSettings(meta), ...patch };
|
|
20
|
+
return [meta.replace(settingPattern, "").trim(), `lc-width=${settings.width}`, `lc-wrap=${settings.wrap}`, `lc-theme=${settings.theme}`,
|
|
21
|
+
...(settings.caption ? [`lc-caption=${encodeURIComponent(settings.caption)}`] : [])].filter(Boolean).join(" ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The editor and both publication renderers use the same token classes and palette.
|
|
25
|
+
export const codeBlockStyles = `
|
|
26
|
+
[data-lettercamp-code] { --code-keyword: #8250a0; --code-string: #3d7757; --code-number: #a15c32; --code-name: #326b99; --code-comment: #93939b; color: #414146; }
|
|
27
|
+
[data-lettercamp-code][data-code-width] { margin-block: 24px; }
|
|
28
|
+
[data-lettercamp-code][data-code-theme="ocean"] { --code-keyword: #3d62a8; --code-string: #277c85; --code-number: #98623e; --code-name: #6961a4; }
|
|
29
|
+
[data-lettercamp-code][data-code-theme="forest"] { --code-keyword: #47724a; --code-string: #827030; --code-number: #a25d47; --code-name: #3c7b79; }
|
|
30
|
+
[data-lettercamp-code][data-code-theme="mono"] { --code-keyword: #45454a; --code-string: #62626a; --code-number: #62626a; --code-name: #45454a; }
|
|
31
|
+
[data-lettercamp-code] .tok-keyword, [data-lettercamp-code] .tok-operator { color: var(--code-keyword); }
|
|
32
|
+
[data-lettercamp-code] .tok-string, [data-lettercamp-code] .tok-string2, [data-lettercamp-code] .tok-inserted { color: var(--code-string); }
|
|
33
|
+
[data-lettercamp-code] .tok-number, [data-lettercamp-code] .tok-bool, [data-lettercamp-code] .tok-atom, [data-lettercamp-code] .tok-literal { color: var(--code-number); }
|
|
34
|
+
[data-lettercamp-code] .tok-typeName, [data-lettercamp-code] .tok-className, [data-lettercamp-code] .tok-propertyName, [data-lettercamp-code] .tok-definition, [data-lettercamp-code] .tok-variableName2, [data-lettercamp-code] .tok-labelName { color: var(--code-name); }
|
|
35
|
+
[data-lettercamp-code] .tok-comment, [data-lettercamp-code] .tok-meta { color: var(--code-comment); }
|
|
36
|
+
[data-lettercamp-code] .tok-invalid, [data-lettercamp-code] .tok-deleted { color: #aa4747; }
|
|
37
|
+
[data-lettercamp-code] .tok-strong { font-weight: 600; }
|
|
38
|
+
[data-lettercamp-code] .tok-emphasis { font-style: italic; }
|
|
39
|
+
figure[data-lettercamp-code] > pre { box-sizing: border-box; margin: 0; padding: 16px 0; overflow-x: auto; border-radius: 8px; background: #fafafa; color: #414146; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
|
|
40
|
+
figure[data-lettercamp-code] > pre > code { display: block; width: max-content; min-width: 100%; padding: 0; background: transparent; color: inherit; font: inherit; white-space: normal; counter-reset: code-line; }
|
|
41
|
+
[data-lettercamp-code] .code-line { position: relative; display: block; box-sizing: border-box; min-height: 1.7em; padding: 0 18px 0 calc(var(--code-gutter, 3ch) + 24px); white-space: pre; }
|
|
42
|
+
[data-lettercamp-code] .code-line::before { position: absolute; left: 8px; width: var(--code-gutter, 3ch); text-align: right; content: counter(code-line); counter-increment: code-line; color: #b4b4bc; user-select: none; font-variant-numeric: tabular-nums; }
|
|
43
|
+
[data-lettercamp-code][data-code-wrap="true"] > pre > code { width: 100%; }
|
|
44
|
+
[data-lettercamp-code][data-code-wrap="true"] .code-line { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
45
|
+
figure[data-lettercamp-code] > figcaption { margin: 8px 0 0; color: #888; font: 13px/1.5 var(--font-family, inherit); text-align: center; overflow-wrap: anywhere; }
|
|
46
|
+
`;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/font-resources.js. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
/** @param {Record<string, any>} typography */
|
|
3
|
+
export function fontResources(typography = {}) {
|
|
4
|
+
const quote = value => '"' + Array.from(String(value), char => /[a-zA-Z0-9 -]/.test(char) ? char : '\\' + char.codePointAt(0).toString(16) + ' ').join('') + '"';
|
|
5
|
+
const google = new Set();
|
|
6
|
+
const custom = new Map();
|
|
7
|
+
const faces = [];
|
|
8
|
+
const resolve = value => {
|
|
9
|
+
if (value && typeof value.url === 'string' && !(typeof value.google === 'string' && value.google.trim())) {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value.url);
|
|
12
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new Error();
|
|
13
|
+
const weight = typeof value.weight === 'string' && /^(?:[1-9]\d{0,2}|1000)(?: (?:[1-9]\d{0,2}|1000))?$/.test(value.weight) ? value.weight : '400';
|
|
14
|
+
const key = url.href + ' ' + weight;
|
|
15
|
+
if (!custom.has(key)) {
|
|
16
|
+
const family = 'Lettercamp Custom ' + custom.size;
|
|
17
|
+
custom.set(key, family);
|
|
18
|
+
faces.push('@font-face{font-family:' + quote(family) + ';src:url(' + quote(url.href) + ');font-weight:' + weight + ';font-style:normal;font-display:swap;}');
|
|
19
|
+
}
|
|
20
|
+
return quote(custom.get(key)) + ', Arial, Helvetica, sans-serif';
|
|
21
|
+
} catch { /* Invalid custom URLs use the default family. */ }
|
|
22
|
+
}
|
|
23
|
+
const family = value && typeof value.google === 'string' && value.google.trim() ? value.google.trim() : 'Inter';
|
|
24
|
+
google.add(family);
|
|
25
|
+
return quote(family) + ', Arial, Helvetica, sans-serif';
|
|
26
|
+
};
|
|
27
|
+
const headingFamily = resolve(typography.headingFont);
|
|
28
|
+
const bodyFamily = resolve(typography.bodyFont);
|
|
29
|
+
return {
|
|
30
|
+
headingFamily, bodyFamily, css: faces.join('\n'),
|
|
31
|
+
googleUrl: google.size ? 'https://fonts.googleapis.com/css2?' + [...google].map(family => 'family=' + encodeURIComponent(family) + ':wght@400;500;600;700').join('&') + '&display=swap' : null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/home-layout.ts. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
export function readHomeWidthPx(layout: { contentWidthPx?: unknown } = {}) {
|
|
3
|
+
const width = layout.contentWidthPx;
|
|
4
|
+
return typeof width === "number" && Number.isFinite(width) && width > 0 ? width : 1280;
|
|
5
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/image-rounding.ts. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
export const defaultImageRadius = 2;
|
|
3
|
+
export function readImageRadius(value: unknown): number {
|
|
4
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 20 ? value : defaultImageRadius;
|
|
5
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { readImageRadius } from "./image-radius";
|
|
2
|
+
|
|
3
|
+
export const siteImageSelector = ".post-card-cover, .cover img, .prose img, .article-hero-image img";
|
|
4
|
+
// Observe published and preview content, including live editor replacements.
|
|
5
|
+
export const imageRoundingScript = `(
|
|
6
|
+
function observeImageSizes(root, selector) {
|
|
7
|
+
const observed = new Set();
|
|
8
|
+
const resize = new ResizeObserver(entries => {
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const box = entry.borderBoxSize[0];
|
|
11
|
+
const size = box ? Math.min(box.inlineSize, box.blockSize) : Math.min(entry.contentRect.width, entry.contentRect.height);
|
|
12
|
+
entry.target.style.setProperty("--image-short-side", size + "px");
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
const sync = () => {
|
|
16
|
+
for (const target of observed) {
|
|
17
|
+
if (!root.contains(target)) { resize.unobserve(target); observed.delete(target); }
|
|
18
|
+
}
|
|
19
|
+
for (const target of root.querySelectorAll(selector)) {
|
|
20
|
+
if (!observed.has(target)) { observed.add(target); resize.observe(target); }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
sync();
|
|
24
|
+
const mutations = new MutationObserver(sync);
|
|
25
|
+
mutations.observe(root, { childList: true, subtree: true });
|
|
26
|
+
return () => { resize.disconnect(); mutations.disconnect(); };
|
|
27
|
+
}
|
|
28
|
+
)(document, ${JSON.stringify(siteImageSelector)});`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const imageWidthStyles = `
|
|
2
|
+
.publication-post { container-type: inline-size; }
|
|
3
|
+
.publication-post .prose video[data-image-width] { width: 100%; height: auto; }
|
|
4
|
+
.publication-post .prose span:has(> img[data-image-width]),
|
|
5
|
+
.publication-post .prose span:has(> a > img[data-image-width]) { display: block; }
|
|
6
|
+
.publication-post .article-hero-image { width: min(100%, var(--post-content-width)); max-width: none; margin-inline: auto; }
|
|
7
|
+
.publication-post .article-hero-image:not([data-image-width="regular"]):not([data-image-width="full"]) { width: min(100%, calc(var(--post-content-width) + 128px)); max-width: none; }
|
|
8
|
+
.publication-post .article-hero-image[data-image-width="full"] { width: 100%; max-width: none; }
|
|
9
|
+
.publication-post .article-hero-image[data-image-width="regular"] { width: min(100%, var(--cover-image-width, var(--post-content-width)), var(--post-content-width)); max-width: none; }
|
|
10
|
+
.publication-post .article-hero:has(.article-hero-image:not([data-image-width="regular"])) { display: block; }
|
|
11
|
+
.publication-post .article-hero:has(.article-hero-image:not([data-image-width="regular"])) .article-intro { max-width: var(--post-content-width); margin-inline: auto; }
|
|
12
|
+
.publication-post .article-hero:has(.article-hero-image:not([data-image-width="regular"])) .article-hero-image { margin-top: 32px; }
|
|
13
|
+
`;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/page-metadata.ts. Run pnpm sync:renderer in the app repository.
|
|
2
|
+
export function pageMetadata(site, page, url, article = false, basePath = "") {
|
|
3
|
+
const seo = site.seo && typeof site.seo === "object" ? site.seo : {};
|
|
4
|
+
const text = (value) => typeof value === "string" ? value.trim() : "";
|
|
5
|
+
const title = text(page.title) || text(site.name);
|
|
6
|
+
const pageTitle = title === site.name ? title : `${title} — ${text(site.name)}`;
|
|
7
|
+
const homepage = !article && new URL(url).pathname.replace(/\/+$/, "") === basePath.replace(/\/+$/, "");
|
|
8
|
+
const documentTitle = text(page.metaTitle) || (homepage && text(seo.metaTitle)) || pageTitle;
|
|
9
|
+
const description = text(page.metaDescription) || text(page.description) || text(site.description);
|
|
10
|
+
const socialTitle = text(page.ogTitle) || (homepage && (text(seo.ogTitle) || text(seo.metaTitle))) || title;
|
|
11
|
+
const socialDescription = text(page.ogDescription) || text(page.description) || text(seo.ogDescription) || description;
|
|
12
|
+
const canonical = new URL(url);
|
|
13
|
+
canonical.search = "";
|
|
14
|
+
canonical.hash = "";
|
|
15
|
+
const resolveUrl = (value) => {
|
|
16
|
+
try {
|
|
17
|
+
const resolved = text(value) && new URL(text(value), canonical);
|
|
18
|
+
return resolved && ["https:", "http:"].includes(resolved.protocol) ? resolved.href : "";
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
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));
|
|
26
|
+
const twitterImageUrl = resolveUrl(page.twitterImage) || imageUrl;
|
|
27
|
+
const language = text(page.language) || text(site.language) || "en";
|
|
28
|
+
let locale = "";
|
|
29
|
+
try {
|
|
30
|
+
const parsed = new Intl.Locale(language).maximize();
|
|
31
|
+
locale = `${parsed.language}_${parsed.region}`;
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
const keywords = page.keywords || seo.keywords || "";
|
|
35
|
+
return {
|
|
36
|
+
documentTitle, description, socialTitle, socialDescription, imageUrl, imageAlt, language, locale,
|
|
37
|
+
canonical: resolveUrl(page.canonicalUrl) || canonical.href,
|
|
38
|
+
keywords: Array.isArray(keywords) ? keywords.filter(value => typeof value === "string").join(", ") : text(keywords),
|
|
39
|
+
robots: page.noindex === true ? "noindex, follow" : "index, follow",
|
|
40
|
+
videoUrl: resolveUrl(page.ogVideo), videoType: text(page.ogVideoType),
|
|
41
|
+
twitterTitle: text(page.twitterTitle) || socialTitle,
|
|
42
|
+
twitterDescription: text(page.twitterDescription) || socialDescription,
|
|
43
|
+
twitterImageUrl, twitterImageAlt: text(page.twitterImageAlt) || (page.twitterImage ? "" : imageAlt),
|
|
44
|
+
twitterCard: ["summary", "summary_large_image"].includes(text(page.twitterCard)) ? text(page.twitterCard) : twitterImageUrl ? "summary_large_image" : "summary",
|
|
45
|
+
twitterSite: text(page.twitterSite), twitterCreator: text(page.twitterCreator),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -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. Run pnpm sync:renderer in the app repository.
|
|
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
|
+
}
|