@lettercamp/cli 0.1.9 → 0.1.11

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/build.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { publicationLinks } from './publication-links.js';
2
2
  import { codeBlocks } from "./code-highlight.js";
3
+ import { contentComponents } from "./content-component-syntax.js";
3
4
  import { componentBlocks } from "./component-blocks.js";
4
5
  import path from 'node:path';
5
6
  import { build } from 'astro';
@@ -38,7 +39,7 @@ export async function buildSite(directory, options) {
38
39
  publicDir: path.join(workspace, '.lettercamp-no-public'), outDir, output: 'static',
39
40
  integrations: [mdx(), react()],
40
41
  vite: reactViteConfig,
41
- markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
42
+ markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
42
43
  build: { format: 'directory', ...(options.assetsPrefix ? { assetsPrefix: options.assetsPrefix } : {}) },
43
44
  });
44
45
  return outDir;
@@ -27,24 +27,24 @@ export function writeCodeSettings(meta, patch) {
27
27
  }
28
28
  // The editor and both publication renderers use the same token classes and palette.
29
29
  export const codeBlockStyles = `
30
- [data-lettercamp-code] { --code-keyword: #8250a0; --code-string: #3d7757; --code-number: #a15c32; --code-name: #326b99; --code-comment: #93939b; color: #414146; }
30
+ [data-lettercamp-code] { --code-keyword: color-mix(in srgb, #8250a0, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-string: color-mix(in srgb, #3d7757, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-number: color-mix(in srgb, #a15c32, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-name: color-mix(in srgb, #326b99, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-comment: var(--site-text-muted, #93939b); color: var(--site-text-strong, #414146); }
31
31
  [data-lettercamp-code][data-code-width] { margin-block: 24px; }
32
- [data-lettercamp-code][data-code-theme="ocean"] { --code-keyword: #3d62a8; --code-string: #277c85; --code-number: #98623e; --code-name: #6961a4; }
33
- [data-lettercamp-code][data-code-theme="forest"] { --code-keyword: #47724a; --code-string: #827030; --code-number: #a25d47; --code-name: #3c7b79; }
34
- [data-lettercamp-code][data-code-theme="mono"] { --code-keyword: #45454a; --code-string: #62626a; --code-number: #62626a; --code-name: #45454a; }
32
+ [data-lettercamp-code][data-code-theme="ocean"] { --code-keyword: color-mix(in srgb, #3d62a8, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-string: color-mix(in srgb, #277c85, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-number: color-mix(in srgb, #98623e, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-name: color-mix(in srgb, #6961a4, var(--site-foreground, #000000) var(--site-code-tint, 0%)); }
33
+ [data-lettercamp-code][data-code-theme="forest"] { --code-keyword: color-mix(in srgb, #47724a, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-string: color-mix(in srgb, #827030, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-number: color-mix(in srgb, #a25d47, var(--site-foreground, #000000) var(--site-code-tint, 0%)); --code-name: color-mix(in srgb, #3c7b79, var(--site-foreground, #000000) var(--site-code-tint, 0%)); }
34
+ [data-lettercamp-code][data-code-theme="mono"] { --code-keyword: var(--site-text-strong, #45454a); --code-string: var(--site-text-secondary, #62626a); --code-number: var(--site-text-secondary, #62626a); --code-name: var(--site-text-strong, #45454a); }
35
35
  [data-lettercamp-code] .tok-keyword, [data-lettercamp-code] .tok-operator { color: var(--code-keyword); }
36
36
  [data-lettercamp-code] .tok-string, [data-lettercamp-code] .tok-string2, [data-lettercamp-code] .tok-inserted { color: var(--code-string); }
37
37
  [data-lettercamp-code] .tok-number, [data-lettercamp-code] .tok-bool, [data-lettercamp-code] .tok-atom, [data-lettercamp-code] .tok-literal { color: var(--code-number); }
38
38
  [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); }
39
39
  [data-lettercamp-code] .tok-comment, [data-lettercamp-code] .tok-meta { color: var(--code-comment); }
40
- [data-lettercamp-code] .tok-invalid, [data-lettercamp-code] .tok-deleted { color: #aa4747; }
40
+ [data-lettercamp-code] .tok-invalid, [data-lettercamp-code] .tok-deleted { color: color-mix(in srgb, #aa4747, var(--site-foreground, #000000) var(--site-code-tint, 0%)); }
41
41
  [data-lettercamp-code] .tok-strong { font-weight: 600; }
42
42
  [data-lettercamp-code] .tok-emphasis { font-style: italic; }
43
- 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; }
43
+ figure[data-lettercamp-code] > pre { box-sizing: border-box; margin: 0; padding: 16px 0; overflow-x: auto; border-radius: 8px; background: var(--site-surface, #fafafa); color: var(--site-text-strong, #414146); font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
44
44
  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; }
45
45
  [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; }
46
- [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; }
46
+ [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: var(--site-text-faint, #b4b4bc); user-select: none; font-variant-numeric: tabular-nums; }
47
47
  [data-lettercamp-code][data-code-wrap="true"] > pre > code { width: 100%; }
48
48
  [data-lettercamp-code][data-code-wrap="true"] .code-line { white-space: pre-wrap; overflow-wrap: anywhere; }
49
- 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; }
49
+ figure[data-lettercamp-code] > figcaption { margin: 8px 0 0; color: var(--site-text-muted, #888); font: 13px/1.5 var(--font-family, inherit); text-align: center; overflow-wrap: anywhere; }
50
50
  `;
@@ -0,0 +1,75 @@
1
+ // Generated from lettercamp/lib/content-component-syntax.ts.
2
+ import { mdxjs } from "micromark-extension-mdxjs";
3
+ import { fromMarkdown } from "mdast-util-from-markdown";
4
+ import { gfm } from "micromark-extension-gfm";
5
+ import { gfmFromMarkdown } from "mdast-util-gfm";
6
+ import { mdxFromMarkdown } from "mdast-util-mdx";
7
+ import { contentComponentNames, isContentComponent, renderContentComponents } from "./content-components.js";
8
+ // Opt .md files into JSX only for built-in tags. Ordinary HTML, autolinks,
9
+ // indented code, and prose containing braces retain normal Markdown behavior.
10
+ export function contentComponentSyntax() {
11
+ const jsx = mdxjs();
12
+ const gate = (construct) => ({
13
+ name: "lettercampComponent",
14
+ tokenize(effects, ok, nok) {
15
+ const check = { partial: true, tokenize(effects, ok, nok) {
16
+ let name = "", first = true;
17
+ return start;
18
+ function start(code) {
19
+ if (first) {
20
+ first = false;
21
+ effects.enter("data");
22
+ effects.consume(code);
23
+ return start;
24
+ }
25
+ if (code === 47 && !name) {
26
+ effects.consume(code);
27
+ return nameStart;
28
+ }
29
+ return nameStart(code);
30
+ }
31
+ function nameStart(code) {
32
+ if (code !== null && /[A-Za-z]/.test(String.fromCharCode(code))) {
33
+ name += String.fromCharCode(code);
34
+ effects.consume(code);
35
+ return nameStart;
36
+ }
37
+ effects.exit("data");
38
+ return contentComponentNames.includes(name) && (code === 62 || code === 47 || code === 32 || code === 9 || code === -4 || code === -5 || code === -3) ? ok(code) : nok(code);
39
+ }
40
+ } };
41
+ return effects.check(check, effects.attempt(construct, ok, nok), nok);
42
+ },
43
+ });
44
+ return { flow: { 60: gate(jsx.flow[60]) }, text: { 60: gate(jsx.text[60]) } };
45
+ }
46
+ // JSX indentation inside a built-in is layout, not an indented code block.
47
+ // Reparse only those source ranges, retaining ordinary Markdown everywhere else.
48
+ export function normalizeContentComponentSyntax(tree, source) {
49
+ const visit = (node) => {
50
+ if (isContentComponent(node) && node.position?.start.offset !== undefined && node.position.end.offset !== undefined) {
51
+ const parsed = fromMarkdown(source.slice(node.position.start.offset, node.position.end.offset), {
52
+ extensions: [gfm(), contentComponentSyntax(), { disable: { null: ["codeIndented"] } }],
53
+ mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
54
+ });
55
+ if (parsed.children.length === 1 && isContentComponent(parsed.children[0]))
56
+ return parsed.children[0];
57
+ }
58
+ if ("children" in node)
59
+ node.children = node.children.map(visit);
60
+ return node;
61
+ };
62
+ tree.children = tree.children.map(visit);
63
+ }
64
+ export function contentComponents() {
65
+ const data = this.data();
66
+ for (const [name, extension] of [["micromarkExtensions", contentComponentSyntax()], ["fromMarkdownExtensions", mdxFromMarkdown()]]) {
67
+ const extensions = (data[name] ??= []);
68
+ extensions.push(extension);
69
+ }
70
+ return (tree, file) => {
71
+ if (file.extname !== ".mdx")
72
+ normalizeContentComponentSyntax(tree, String(file.value));
73
+ renderContentComponents(tree);
74
+ };
75
+ }
@@ -0,0 +1,222 @@
1
+ import { readCodeSettings, writeCodeSettings } from "./code-block.js";
2
+ import { textStyleString } from "./text-styles.js";
3
+ export const contentComponentNames = ["KeyTakeaways", "Image", "Video", "Audio", "CodeBlock", "Table", "TableRow", "TableCell"];
4
+ export const isContentElement = (node) => node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement";
5
+ export const isContentComponent = (node) => isContentElement(node) && contentComponentNames.includes(node.name);
6
+ // Only literal values are interpreted. Expressions, spreads, and user code stay opaque.
7
+ export function componentProps(node) {
8
+ const props = {};
9
+ for (const attribute of node.attributes) {
10
+ if (attribute.type !== "mdxJsxAttribute" || Object.hasOwn(props, attribute.name))
11
+ return null;
12
+ if (attribute.value == null)
13
+ props[attribute.name] = true;
14
+ else if (typeof attribute.value === "string")
15
+ props[attribute.name] = attribute.value;
16
+ else {
17
+ const statement = attribute.value.data?.estree?.body[0];
18
+ const expression = statement?.type === "ExpressionStatement" ? statement.expression : null;
19
+ if (expression?.type === "Literal" && ["string", "number", "boolean"].includes(typeof expression.value))
20
+ props[attribute.name] = expression.value;
21
+ else if (expression?.type === "ArrayExpression" && expression.elements.every(item => item?.type === "Literal" && typeof item.value === "number")) {
22
+ props[attribute.name] = expression.elements.map(item => item.value);
23
+ }
24
+ else {
25
+ // Source writers can construct literal attributes before a parser has
26
+ // attached ESTree metadata. JSON parsing never evaluates user code.
27
+ let value;
28
+ try {
29
+ value = JSON.parse(attribute.value.value);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (["string", "number", "boolean"].includes(typeof value) || Array.isArray(value) && value.every(item => typeof item === "number"))
35
+ props[attribute.name] = value;
36
+ else
37
+ return null;
38
+ }
39
+ }
40
+ }
41
+ return props;
42
+ }
43
+ export function contentElement(name, props, children = [], inline = false) {
44
+ return {
45
+ type: inline ? "mdxJsxTextElement" : "mdxJsxFlowElement", name,
46
+ attributes: Object.entries(props).filter(([, value]) => value !== undefined).map(([name, value]) => ({
47
+ type: "mdxJsxAttribute", name, value: typeof value === "string" ? value : value === true ? null : {
48
+ type: "mdxJsxAttributeValueExpression", value: JSON.stringify(value),
49
+ },
50
+ })), children, data: { lettercampBuiltin: true },
51
+ };
52
+ }
53
+ // Legacy HTML uses string attributes so existing editor visitors can read it.
54
+ const html = (name, props, children = [], inline = false) => contentElement(name, Object.fromEntries(Object.entries(props).map(([key, value]) => [key, value === undefined ? undefined : String(value)])), children, inline);
55
+ const meaningful = (nodes) => nodes.filter(node => node.type !== "text" || node.value.trim());
56
+ const widthOf = (value) => value === "wide" || value === "full" ? value : "regular";
57
+ const positive = (value) => Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : undefined;
58
+ const allowed = (props, names) => Object.keys(props).every(name => names.includes(name));
59
+ export function expandContentComponent(node) {
60
+ if (!isContentComponent(node))
61
+ return null;
62
+ const props = componentProps(node);
63
+ if (!props)
64
+ return null;
65
+ if (props.width !== undefined && !["regular", "wide", "full"].includes(String(props.width)))
66
+ return null;
67
+ const inline = node.type === "mdxJsxTextElement";
68
+ switch (node.name) {
69
+ case "KeyTakeaways":
70
+ return allowed(props, ["width"]) && !inline ? html("div", { "data-lettercamp-key-takeaways": "true", "data-block-width": widthOf(props.width) }, node.children) : null;
71
+ case "Image":
72
+ case "Video":
73
+ case "Audio": {
74
+ const image = node.name === "Image", audio = node.name === "Audio";
75
+ if (!allowed(props, ["src", "alt", "width", "caption", "title", "pixelWidth", "pixelHeight", ...(image ? ["href", "newTab"] : [])]) || typeof props.src !== "string" || meaningful(node.children).length)
76
+ return null;
77
+ if (props.newTab !== undefined && typeof props.newTab !== "boolean")
78
+ return null;
79
+ let media = html(image ? "img" : audio ? "audio" : "video", {
80
+ src: props.src, ...(image ? { alt: props.alt ?? "" } : { controls: "" }), title: props.title,
81
+ width: positive(props.pixelWidth), height: positive(props.pixelHeight),
82
+ "data-image-width": audio ? undefined : widthOf(props.width), "data-block-width": audio ? widthOf(props.width) : undefined,
83
+ }, [], inline);
84
+ if (image && typeof props.href === "string" && props.href)
85
+ media = html("a", { href: props.href, ...(props.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {}) }, [media], inline);
86
+ if (typeof props.caption === "string" && props.caption) {
87
+ const caption = { type: "text", value: props.caption };
88
+ media = html(inline ? "span" : "figure", {}, [media, html(inline ? "small" : "figcaption", {}, [inline ? caption : { type: "paragraph", children: [caption] }], inline)], inline);
89
+ }
90
+ return media;
91
+ }
92
+ case "CodeBlock": {
93
+ const children = meaningful(node.children), code = children[0];
94
+ if (!allowed(props, ["width", "caption", "wrap", "theme"]) || inline || children.length !== 1 || code?.type !== "code")
95
+ return null;
96
+ if (props.wrap !== undefined && typeof props.wrap !== "boolean")
97
+ return null;
98
+ if (props.theme !== undefined && !["classic", "ocean", "forest", "mono"].includes(String(props.theme)))
99
+ return null;
100
+ const settings = readCodeSettings(code.meta ?? "");
101
+ return { ...code, meta: writeCodeSettings(code.meta ?? "", { ...settings,
102
+ ...(props.width !== undefined ? { width: widthOf(props.width) } : {}),
103
+ ...(typeof props.caption === "string" ? { caption: props.caption } : {}),
104
+ ...(typeof props.wrap === "boolean" ? { wrap: props.wrap } : {}),
105
+ ...(props.theme !== undefined ? { theme: props.theme } : {}),
106
+ }) };
107
+ }
108
+ case "Table": return !inline ? expandTable(node, props) : null;
109
+ default: return null;
110
+ }
111
+ }
112
+ function expandTable(node, props) {
113
+ if (!allowed(props, ["width", "columnWidths", "rowHeight"]))
114
+ return null;
115
+ const children = meaningful(node.children.flatMap(child => child.type === "paragraph" ? child.children : [child]));
116
+ let rows;
117
+ if (children.length === 1 && children[0].type === "table") {
118
+ const table = children[0];
119
+ rows = table.children.map((row, r) => contentElement("TableRow", {}, row.children.map((cell, c) => contentElement("TableCell", {
120
+ header: r === 0 || undefined, align: table.align?.[c] ?? undefined,
121
+ }, [{ type: "paragraph", children: cell.children }]))));
122
+ }
123
+ else if (children.every(child => isContentElement(child) && child.name === "TableRow"))
124
+ rows = children;
125
+ else
126
+ return null;
127
+ if (!rows.length)
128
+ return null;
129
+ const converted = [];
130
+ const occupied = rows.map(() => []);
131
+ const heights = rows.map(row => positive(componentProps(row)?.height) ?? positive(props.rowHeight));
132
+ let columnCount = 0;
133
+ for (const [r, row] of rows.entries()) {
134
+ const rowProps = componentProps(row);
135
+ if (!rowProps || !allowed(rowProps, ["height"]))
136
+ return null;
137
+ const cells = [];
138
+ let c = 0;
139
+ for (const cell of meaningful(row.children.flatMap(child => child.type === "paragraph" ? child.children : [child]))) {
140
+ if (!isContentElement(cell) || cell.name !== "TableCell")
141
+ return null;
142
+ const p = componentProps(cell);
143
+ if (!p || !allowed(p, ["header", "colSpan", "rowSpan", "align", "color", "background"]) || p.header !== undefined && typeof p.header !== "boolean")
144
+ return null;
145
+ while (occupied[r][c])
146
+ c++;
147
+ const colSpan = Math.max(1, Math.floor(positive(p.colSpan) ?? 1)), rowSpan = Math.min(rows.length - r, Math.max(1, Math.floor(positive(p.rowSpan) ?? 1)));
148
+ for (let y = r; y < r + rowSpan; y++)
149
+ for (let x = c; x < c + colSpan; x++)
150
+ occupied[y][x] = true;
151
+ const style = textStyleString([p.color && `color: ${p.color}`, p.background && `background-color: ${p.background}`, p.align && `text-align: ${p.align}`].filter(Boolean).join("; "));
152
+ const cellHeights = heights.slice(r, r + rowSpan);
153
+ const height = (rowSpan > 1 || colSpan > 1) && cellHeights.every(value => value !== undefined) ? Math.max(0, cellHeights.reduce((sum, value) => sum + value, 0) - 1) : undefined;
154
+ // The same content viewport preserves merged-cell heights in editor and output.
155
+ cells.push(html(p.header ? "th" : "td", { colSpan: colSpan > 1 ? colSpan : undefined, rowSpan: rowSpan > 1 ? rowSpan : undefined,
156
+ style: [style, height !== undefined ? `--table-cell-height: ${height}px` : ""].filter(Boolean).join("; ") || undefined,
157
+ "data-table-cell-height": height,
158
+ }, [html("span", { "data-table-content": "true" }, cell.children.flatMap(child => child.type === "paragraph" ? child.children : [child]), true)]));
159
+ c += colSpan;
160
+ }
161
+ columnCount = Math.max(columnCount, occupied[r].length);
162
+ converted.push(html("tr", { height: heights[r] }, cells));
163
+ }
164
+ const requested = Array.isArray(props.columnWidths) ? props.columnWidths : typeof props.columnWidths === "string" ? props.columnWidths.split(",").map(Number) : undefined;
165
+ if (requested && (requested.length !== columnCount || requested.some(value => !Number.isFinite(value) || value <= 0)))
166
+ return null;
167
+ const widths = requested ?? Array(columnCount).fill(1), total = widths.reduce((sum, width) => sum + width, 0);
168
+ return html("div", { "data-lettercamp-table": "true", "data-table-width": widthOf(props.width), "data-row-height": positive(props.rowHeight) }, [html("table", {}, [
169
+ html("colgroup", {}, widths.map(width => html("col", { width: `${width / total * 100}%` }))), html("tbody", {}, converted),
170
+ ])]);
171
+ }
172
+ export function codeComponent(code) {
173
+ const settings = readCodeSettings(code.meta ?? "");
174
+ const meta = (code.meta ?? "").replace(/(?:^|\s)lc-(width|wrap|theme|caption)=[^\s]*/g, "").trim();
175
+ return contentElement("CodeBlock", {
176
+ width: settings.width === "regular" ? undefined : settings.width,
177
+ wrap: settings.wrap ? undefined : false, theme: settings.theme === "classic" ? undefined : settings.theme,
178
+ caption: settings.caption || undefined,
179
+ }, [{ ...code, meta: meta || null }]);
180
+ }
181
+ // Imported JSX components with matching names retain their original meaning.
182
+ export function importedComponentNames(tree) {
183
+ const names = new Set();
184
+ for (const node of tree.children)
185
+ if (node.type === "mdxjsEsm") {
186
+ for (const statement of node.data?.estree?.body ?? [])
187
+ if (statement.type === "ImportDeclaration") {
188
+ for (const specifier of statement.specifiers)
189
+ names.add(specifier.local.name);
190
+ }
191
+ }
192
+ return names;
193
+ }
194
+ // Lower built-ins to the existing HTML contract before highlighting and layout.
195
+ // Generic mdast nodes with hName work in both Markdown and Astro's MDX compiler.
196
+ export function renderContentComponents(tree) {
197
+ const imported = importedComponentNames(tree);
198
+ const visit = (node) => {
199
+ if (isContentComponent(node) && !imported.has(node.name)) {
200
+ const expanded = expandContentComponent(node);
201
+ if (!expanded)
202
+ throw new Error(`Invalid <${node.name}> component. Use supported literal properties and children.`);
203
+ return lower(expanded);
204
+ }
205
+ if ("children" in node)
206
+ node.children = node.children.map(visit);
207
+ return node;
208
+ };
209
+ const lower = (node) => {
210
+ if (isContentElement(node) && node.name && /^[a-z]/.test(node.name)) {
211
+ const properties = componentProps(node) ?? {};
212
+ // HAST/Astro require a boolean here; an empty JSX string is falsy and
213
+ // would silently remove playback controls from published MDX pages.
214
+ if (Object.hasOwn(properties, "controls"))
215
+ properties.controls = true;
216
+ const children = node.children.map(child => isContentElement(child) && child.name && /^[a-z]/.test(child.name) ? lower(child) : visit(child));
217
+ return { type: "lettercampElement", data: { hName: node.name, hProperties: properties }, children };
218
+ }
219
+ return visit(node);
220
+ };
221
+ tree.children = tree.children.map(visit);
222
+ }
package/dist/dev.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { codeBlocks } from "./code-highlight.js";
2
+ import { contentComponents } from "./content-component-syntax.js";
2
3
  import { componentBlocks } from "./component-blocks.js";
3
4
  import { rm } from 'node:fs/promises';
4
5
  import path from 'node:path';
@@ -49,10 +50,16 @@ export async function startDev(directory, options) {
49
50
  root: workspace,
50
51
  logLevel: 'warn',
51
52
  configFile: false,
53
+ base: options.base ?? '/',
52
54
  publicDir: path.join(workspace, '.lettercamp-no-public'),
53
55
  output: 'static',
54
- integrations: [mdx(), react()],
55
- markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root: contentRoot }]], rehypePlugins: [[mediaDimensions, { root: contentRoot }]] }) },
56
+ integrations: [mdx(), react(), ...(options.middleware ? [{
57
+ name: 'lettercamp-editor-preview',
58
+ hooks: { 'astro:config:setup': ({ addMiddleware }) => {
59
+ addMiddleware({ entrypoint: options.middleware, order: 'post' });
60
+ } },
61
+ }] : [])],
62
+ markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root: contentRoot }]], rehypePlugins: [[mediaDimensions, { root: contentRoot }]] }) },
56
63
  devToolbar: { enabled: false },
57
64
  server: { port, host: options.host, open: options.open },
58
65
  vite: {
@@ -63,6 +70,14 @@ export async function startDev(directory, options) {
63
70
  name: 'lettercamp-content',
64
71
  configureServer(vite) {
65
72
  closeVite = () => vite.close();
73
+ if (options.middleware)
74
+ vite.middlewares.use((request, _response, next) => {
75
+ // Astro removes query parameters from prerendered page requests.
76
+ // Preserve the original URL through its dev-adapter locals channel.
77
+ const key = Symbol.for('astro.locals');
78
+ Reflect.set(request, key, { ...Reflect.get(request, key), lettercampRequestUrl: request.url });
79
+ next();
80
+ });
66
81
  // Lettercamp has no Socket.IO endpoint. Stray polling clients should
67
82
  // receive a 404 without repeatedly triggering Astro's page warnings.
68
83
  vite.middlewares.use((request, response, next) => {
@@ -96,7 +111,8 @@ export async function startDev(directory, options) {
96
111
  vite.watcher.on('add', updateLayout).on('unlink', updateLayout);
97
112
  },
98
113
  }],
99
- server: { strictPort: false, fs: { allow: [workspace, contentRoot, modules, reactIntegrationDirectory] } },
114
+ server: { strictPort: options.strictPort ?? false, fs: { allow: [workspace, contentRoot, modules, reactIntegrationDirectory,
115
+ ...(options.middleware ? [path.dirname(options.middleware)] : [])] } },
100
116
  },
101
117
  });
102
118
  process.once('SIGINT', stop);
package/dist/hosting.js CHANGED
@@ -55,13 +55,12 @@ export async function selectHostingSite(token, allowCreate) {
55
55
  throw error;
56
56
  }
57
57
  }
58
- const { sites: all } = await request(token, 'sites');
59
- const sites = all.filter(site => site.publishingTarget === 'cloud');
58
+ const { sites } = await request(token, 'sites');
60
59
  const existing = binding && sites.find(site => site.id === binding?.siteId);
61
60
  if (existing && binding)
62
61
  return { site: existing, binding };
63
62
  if (binding)
64
- console.error('The saved site is unavailable for cloud publishing. Choose another site for this folder.');
63
+ console.error('The saved site is unavailable. Choose another site for this folder.');
65
64
  if (!allowCreate && !sites.length)
66
65
  throw new Error('Run lettercamp deploy first to create a site.');
67
66
  const choices = [...(allowCreate ? ['Create a new site'] : []), ...sites.map(site => `${site.name} (${site.slug}.lettercamp.site)`)];
@@ -105,9 +105,10 @@ export function mediaDimensions(options) {
105
105
  });
106
106
  const jsx = ['mdxJsxFlowElement', 'mdxJsxTextElement'].includes(node.type) && node.name === 'img';
107
107
  const markdown = node.type === 'image' || node.type === 'imageReference';
108
- if (jsx || markdown || node.type === 'element' && node.tagName === 'img') {
108
+ const component = node.type === 'lettercampElement' && node.data?.hName === 'img';
109
+ if (jsx || markdown || component || node.type === 'element' && node.tagName === 'img') {
109
110
  const properties = jsx ? Object.fromEntries((node.attributes ?? []).filter(a => a.type === 'mdxJsxAttribute').map(a => [a.name, a.value]))
110
- : markdown ? node.data?.hProperties ?? {} : node.properties ?? {};
111
+ : markdown || component ? node.data?.hProperties ?? {} : node.properties ?? {};
111
112
  const src = markdown ? node.url ?? definitions.get(node.identifier?.toLowerCase() ?? '') : properties.src;
112
113
  const width = positive(properties.width), height = positive(properties.height);
113
114
  // Leave dynamic dimensions and explicit sizing under the author's control.
@@ -126,7 +127,7 @@ export function mediaDimensions(options) {
126
127
  for (const [name, value] of Object.entries(attrs))
127
128
  node.attributes.push({ type: 'mdxJsxAttribute', name, value: String(value) });
128
129
  }
129
- else if (markdown)
130
+ else if (markdown || component)
130
131
  node.data = { ...node.data, hProperties: { ...properties, ...attrs } };
131
132
  else
132
133
  node.properties = { ...properties, ...attrs };
@@ -9,6 +9,13 @@ export function publicationLinks(options) {
9
9
  function visit(node) {
10
10
  if (['link', 'image', 'definition'].includes(node.type) && node.url)
11
11
  node.url = prefix(node.url);
12
+ if (node.type === 'lettercampElement') {
13
+ const properties = node.data?.hProperties;
14
+ for (const name of ['href', 'src', 'poster']) {
15
+ if (properties && typeof properties[name] === 'string')
16
+ properties[name] = prefix(properties[name]);
17
+ }
18
+ }
12
19
  if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
13
20
  for (const attribute of node.attributes ?? []) {
14
21
  if (attribute.type === 'mdxJsxAttribute' && ['href', 'src', 'poster'].includes(attribute.name ?? '') && typeof attribute.value === 'string')
@@ -0,0 +1,23 @@
1
+ // Generated from lettercamp/lib/text-styles.ts.
2
+ // The same small CSS vocabulary is used by the editor and the safe preview.
3
+ // Never pass arbitrary author CSS through the preview sanitizer.
4
+ export function textStyles(style) {
5
+ const result = {};
6
+ if (typeof style !== "string")
7
+ return result;
8
+ for (const declaration of style.split(";")) {
9
+ const [property, ...parts] = declaration.split(":");
10
+ const name = property.trim().toLowerCase(), value = parts.join(":").trim().toLowerCase();
11
+ if (name === "text-align" && /^(left|center|right)$/.test(value))
12
+ result[name] = value;
13
+ if (["color", "background-color"].includes(name) &&
14
+ (/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/.test(value) ||
15
+ /^(?:rgb|hsl)a?\([\d.% ,/+-]+\)$/.test(value) ||
16
+ /^(?:black|white|red|green|blue|yellow|orange|purple|pink|gray|grey|transparent|inherit|currentcolor)$/.test(value)))
17
+ result[name] = value;
18
+ }
19
+ return result;
20
+ }
21
+ export function textStyleString(style) {
22
+ return Object.entries(textStyles(style)).map(([property, value]) => `${property}: ${value}`).join("; ");
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lettercamp/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "The repository for the Lettercamp CLI",
5
5
  "keywords": [
6
6
  "lettercamp",
@@ -49,6 +49,12 @@
49
49
  "hast-util-from-html": "^2.0.3",
50
50
  "hast-util-to-html": "^9.0.5",
51
51
  "highlight.js": "^11.12.0",
52
+ "mdast-util-from-markdown": "^2.1.0",
53
+ "mdast-util-gfm": "^3.1.0",
54
+ "mdast-util-mdx": "^3.0.0",
55
+ "micromark-extension-gfm": "^3.0.0",
56
+ "micromark-extension-mdxjs": "^3.0.0",
57
+ "micromark-util-types": "^2.0.3",
52
58
  "react": "19.3.1",
53
59
  "react-dom": "19.3.1",
54
60
  "react-icons": "^5.7.0",
@@ -16,10 +16,17 @@ const showDate = date?.label;
16
16
  </span>)}
17
17
  </span>
18
18
  <span class="author-names">
19
+ <span class="author-names-desktop">
19
20
  {authors.length <= 2 ? authors.map(author => author.name).join(', ') : <>
20
- {authors.slice(0, 2).map(author => author.name).join(', ')}, and{' '}
21
- <button type="button" class="author-more" aria-haspopup="dialog" data-authors={JSON.stringify(authors)}>{authors.length - 2} more</button>
21
+ {authors.slice(0, 2).map(author => author.name).join(', ')}{' '}
22
+ <button type="button" class="author-more" aria-haspopup="dialog" data-authors={JSON.stringify(authors)}>+ {authors.length - 2} more</button>
23
+ <span class="sr-only">{': ' + authors.slice(2).map(author => author.name).join(', ')}</span>
22
24
  </>}
25
+ </span>
26
+ <span class="author-names-mobile">
27
+ <span class="author-name" title={authors[0].name}>{authors[0].name}</span>
28
+ {authors.length > 1 && <button type="button" class="author-more" aria-haspopup="dialog" data-authors={JSON.stringify(authors)}>+ {authors.length - 1} more {authors.length === 2 ? 'author' : 'authors'}</button>}
29
+ </span>
23
30
  </span>
24
31
  </span>}
25
32
  {showReadingTime && <span class="reading-time">{post.readingMinutes} min read</span>}
@@ -5,7 +5,7 @@ import MobileMenuIcons from './MobileMenuIcons.astro';
5
5
  const { site, pathname } = Astro.props;
6
6
  const navLinks = site.header?.links ?? [{ label: 'Home', href: '/' }];
7
7
  const primary = site.header?.button;
8
- const primaryColors = buttonColors(primary?.color);
8
+ const primaryColors = buttonColors(primary?.color, site.design?.accentColor);
9
9
  const primaryStyle = { '--button-color': primaryColors.background, '--button-text': primaryColors.text };
10
10
  const logo = site.logo;
11
11
  ---
@@ -18,6 +18,6 @@ const hasIcon = icons[platform] || ['instagram', 'website', 'hacker-news'].inclu
18
18
  {icons[platform] && <path d={icons[platform]} />}
19
19
  {platform === 'instagram' && <g fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="5" /><circle cx="12" cy="12" r="4" /><circle cx="17.5" cy="6.5" r=".8" fill="currentColor" stroke="none" /></g>}
20
20
  {platform === 'website' && <g fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9" /><ellipse cx="12" cy="12" rx="4" ry="9" /><path d="M3 12h18" /></g>}
21
- {platform === 'hacker-news' && <><rect x="2" y="2" width="20" height="20" rx="2" /><path d="m7 6 5 7 5-7m-5 7v5" fill="none" stroke="var(--background, #fff)" stroke-width="1.8" /></>}
21
+ {platform === 'hacker-news' && <><rect x="2" y="2" width="20" height="20" rx="2" /><path d="m7 6 5 7 5-7m-5 7v5" fill="none" stroke="var(--site-background, #fff)" stroke-width="1.8" /></>}
22
22
  </svg> : <span>{label}</span>}
23
23
  </a>
@@ -13,6 +13,8 @@ import { postShareScript } from '../lib/post-share';
13
13
  import { mobileNavigationScript } from '../lib/mobile-navigation';
14
14
  import { blockWidthStyles } from '../lib/block-widths';
15
15
  import { tableStyles } from '../lib/table-styles';
16
+ import { keyTakeawaysStyles } from '../lib/key-takeaways';
17
+ import { siteColors } from '../lib/site-colors';
16
18
  import { imageWidthStyles } from '../lib/image-widths';
17
19
  import { googleAnalyticsScript } from '../lib/analytics';
18
20
  import Header from '../components/HeaderSlot.astro';
@@ -21,19 +23,20 @@ import Footer from '../components/FooterSlot.astro';
21
23
  const { site, page, article = false } = Astro.props;
22
24
  const meta = pageMetadata(site, page, Astro.url, article, basePath);
23
25
  const typography = site.design?.typography ?? {};
24
- const fonts = fontResources(typography);
26
+ const fonts = fontResources(typography, article ? 700 : 500);
25
27
  const fontSize = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
26
28
  const baseSize = fontSize(typography.baseSizePx, 16);
27
29
  const bodyLineHeight = fontSize(typography.bodyLineHeight, 1.8);
28
- const backgroundColor = typeof site.design?.backgroundColor === 'string' && /^#[0-9a-f]{6}$/i.test(site.design?.backgroundColor) ? site.design?.backgroundColor : '#ffffff';
30
+ const colors = siteColors(site.design);
29
31
  const analytics = googleAnalyticsScript(site.analytics?.googleAnalyticsId);
30
32
  ---
31
33
 
32
34
  <!doctype html>
33
- <html data-base-path={basePath} lang={meta.language} style={{ '--image-radius': readImageRadius(site.design?.imageRadiusPercent) / 100, '--site-background': backgroundColor, '--font-family': fonts.bodyFamily, '--heading-font': fonts.headingFamily, '--type-scale': baseSize / 16, '--body-line-height': bodyLineHeight }}>
35
+ <html data-base-path={basePath} data-site-accent={colors['--site-accent'] !== 'inherit' ? '' : undefined} lang={meta.language} style={{ ...colors, '--image-radius': readImageRadius(site.design?.imageRadiusPercent) / 100, '--font-family': fonts.bodyFamily, '--heading-font': fonts.headingFamily, '--type-scale': baseSize / 16, '--body-line-height': bodyLineHeight }}>
34
36
  <head>
35
37
  <meta charset="utf-8" />
36
38
  <meta name="viewport" content="width=device-width, initial-scale=1" />
39
+ {fonts.preloads.map(href => <link data-lettercamp-font-preload rel="preload" href={href} as="font" type="font/woff2" crossorigin />)}
37
40
  {fonts.googleUrl && <>
38
41
  <link rel="preconnect" href="https://fonts.googleapis.com" />
39
42
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -44,6 +47,7 @@ const analytics = googleAnalyticsScript(site.analytics?.googleAnalyticsId);
44
47
  <style is:inline set:html={blockWidthStyles} />
45
48
  <style is:inline set:html={codeBlockStyles} />
46
49
  <style is:inline set:html={tableStyles} />
50
+ <style is:inline set:html={keyTakeawaysStyles} />
47
51
  <title>{meta.documentTitle}</title>
48
52
  <meta name="description" content={meta.description} />
49
53
  {meta.keywords && <meta name="keywords" content={meta.keywords} />}
@@ -26,10 +26,4 @@ ${blocks}${full} {
26
26
  max-width: none; margin-inline: calc((100% - var(--block-display-width)) / 2);
27
27
  margin-left: calc((100% - var(--block-display-width)) / 2 - var(--block-column-offset, 0px));
28
28
  }
29
- /* The two-column TOC layout shifts the reading column 106px from center. */
30
- @media (min-width: 761px) and (max-width: 1199px) {
31
- .publication-post[data-post-hero="split"][data-post-toc="visible"] .article-body-with-toc .prose {
32
- --block-column-offset: 106px;
33
- }
34
- }
35
29
  `;
@@ -1,10 +1,12 @@
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' };
1
+ // Generated from lettercamp/lib/button-colors.ts.
2
+ // Shared by settings, the preview bridge, and the published renderer.
3
+ // Keep self-contained so the preview can embed this function.
4
+ export function buttonColors(value, accent) {
5
+ const background = [value, accent].find((color) => typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color)) ?? "#111111";
6
+ const channels = [1, 3, 5].map(offset => {
7
+ const channel = parseInt(background.slice(offset, offset + 2), 16) / 255;
8
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
9
+ });
10
+ const luminance = channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
11
+ return { background, text: (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#ffffff" };
10
12
  }