@lettercamp/cli 0.1.10 → 0.1.12
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 +3 -1
- package/dist/code-block.js +8 -8
- package/dist/content-component-syntax.js +75 -0
- package/dist/content-components.js +244 -0
- package/dist/dev.js +20 -3
- package/dist/media-dimensions.js +4 -3
- package/dist/publication-links.js +7 -0
- package/dist/search-content.js +24 -0
- package/dist/text-styles.js +23 -0
- package/package.json +7 -1
- package/renderer/src/components/AuthorByline.astro +9 -2
- package/renderer/src/components/BlogHome.astro +8 -4
- package/renderer/src/components/Footer.astro +1 -1
- package/renderer/src/components/Header.astro +2 -2
- package/renderer/src/components/PostSearch.astro +30 -0
- package/renderer/src/components/RelatedPosts.astro +11 -0
- package/renderer/src/components/SocialLink.astro +1 -1
- package/renderer/src/layouts/SiteLayout.astro +11 -4
- package/renderer/src/lib/block-widths.ts +0 -6
- package/renderer/src/lib/branding.js +2 -0
- package/renderer/src/lib/button.js +11 -9
- package/renderer/src/lib/code-block.ts +8 -8
- package/renderer/src/lib/faq.ts +25 -0
- package/renderer/src/lib/font-catalog.js +1650 -0
- package/renderer/src/lib/font-fallbacks.js +36 -0
- package/renderer/src/lib/fonts.js +31 -8
- package/renderer/src/lib/image-widths.ts +0 -3
- package/renderer/src/lib/key-takeaways.ts +25 -0
- package/renderer/src/lib/post-search.ts +194 -0
- package/renderer/src/lib/post-share.ts +10 -0
- package/renderer/src/lib/related-posts.ts +50 -0
- package/renderer/src/lib/site-colors.ts +42 -0
- package/renderer/src/lib/table-styles.ts +3 -3
- package/renderer/src/pages/[...slug].astro +14 -3
- package/renderer/src/styles/post-search.css +37 -0
- package/renderer/src/styles/post-share.css +44 -27
- package/renderer/src/styles/site.css +95 -72
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';
|
|
@@ -7,6 +8,7 @@ import mdx from '@astrojs/mdx';
|
|
|
7
8
|
import { unified } from '@astrojs/markdown-remark';
|
|
8
9
|
import { prepareWorkspace } from './workspace.js';
|
|
9
10
|
import { readingTime } from './reading-time.js';
|
|
11
|
+
import { searchContent } from './search-content.js';
|
|
10
12
|
import { mediaDimensions } from './media-dimensions.js';
|
|
11
13
|
import { reactViteConfig } from './react.js';
|
|
12
14
|
export async function buildSite(directory, options) {
|
|
@@ -38,7 +40,7 @@ export async function buildSite(directory, options) {
|
|
|
38
40
|
publicDir: path.join(workspace, '.lettercamp-no-public'), outDir, output: 'static',
|
|
39
41
|
integrations: [mdx(), react()],
|
|
40
42
|
vite: reactViteConfig,
|
|
41
|
-
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
|
|
43
|
+
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, searchContent, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
|
|
42
44
|
build: { format: 'directory', ...(options.assetsPrefix ? { assetsPrefix: options.assetsPrefix } : {}) },
|
|
43
45
|
});
|
|
44
46
|
return outDir;
|
package/dist/code-block.js
CHANGED
|
@@ -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,244 @@
|
|
|
1
|
+
import { readCodeSettings, writeCodeSettings } from "./code-block.js";
|
|
2
|
+
import { textStyleString } from "./text-styles.js";
|
|
3
|
+
export const contentComponentNames = ["KeyTakeaways", "FAQ", "FAQItem", "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 "FAQ": {
|
|
70
|
+
if (inline || !allowed(props, ["width"]))
|
|
71
|
+
return null;
|
|
72
|
+
const items = meaningful(node.children.flatMap(child => child.type === "paragraph" ? child.children : [child]));
|
|
73
|
+
const details = [];
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
if (!isContentElement(item) || item.name !== "FAQItem")
|
|
76
|
+
return null;
|
|
77
|
+
const itemProps = componentProps(item);
|
|
78
|
+
if (!itemProps || !allowed(itemProps, ["question"]) || typeof itemProps.question !== "string")
|
|
79
|
+
return null;
|
|
80
|
+
details.push(html("details", {}, [
|
|
81
|
+
html("summary", {}, [{ type: "paragraph", children: [{ type: "text", value: itemProps.question }] }]),
|
|
82
|
+
html("div", { "data-faq-answer": "true" }, item.children),
|
|
83
|
+
]));
|
|
84
|
+
}
|
|
85
|
+
return html("div", { "data-lettercamp-faq": "true", "data-block-width": widthOf(props.width) }, details);
|
|
86
|
+
}
|
|
87
|
+
case "KeyTakeaways":
|
|
88
|
+
return allowed(props, ["width"]) && !inline ? html("div", { "data-lettercamp-key-takeaways": "true", "data-block-width": widthOf(props.width) }, node.children) : null;
|
|
89
|
+
case "Image":
|
|
90
|
+
case "Video":
|
|
91
|
+
case "Audio": {
|
|
92
|
+
const image = node.name === "Image", audio = node.name === "Audio";
|
|
93
|
+
if (!allowed(props, ["src", "alt", "width", "caption", "title", "pixelWidth", "pixelHeight", ...(image ? ["href", "newTab"] : [])]) || typeof props.src !== "string" || meaningful(node.children).length)
|
|
94
|
+
return null;
|
|
95
|
+
if (props.newTab !== undefined && typeof props.newTab !== "boolean")
|
|
96
|
+
return null;
|
|
97
|
+
let media = html(image ? "img" : audio ? "audio" : "video", {
|
|
98
|
+
src: props.src, ...(image ? { alt: props.alt ?? "" } : { controls: "" }), title: props.title,
|
|
99
|
+
width: positive(props.pixelWidth), height: positive(props.pixelHeight),
|
|
100
|
+
"data-image-width": audio ? undefined : widthOf(props.width), "data-block-width": audio ? widthOf(props.width) : undefined,
|
|
101
|
+
}, [], inline);
|
|
102
|
+
if (image && typeof props.href === "string" && props.href)
|
|
103
|
+
media = html("a", { href: props.href, ...(props.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {}) }, [media], inline);
|
|
104
|
+
if (typeof props.caption === "string" && props.caption) {
|
|
105
|
+
const caption = { type: "text", value: props.caption };
|
|
106
|
+
media = html(inline ? "span" : "figure", {}, [media, html(inline ? "small" : "figcaption", {}, [inline ? caption : { type: "paragraph", children: [caption] }], inline)], inline);
|
|
107
|
+
}
|
|
108
|
+
return media;
|
|
109
|
+
}
|
|
110
|
+
case "CodeBlock": {
|
|
111
|
+
const children = meaningful(node.children), code = children[0];
|
|
112
|
+
if (!allowed(props, ["width", "caption", "wrap", "theme"]) || inline || children.length !== 1 || code?.type !== "code")
|
|
113
|
+
return null;
|
|
114
|
+
if (props.wrap !== undefined && typeof props.wrap !== "boolean")
|
|
115
|
+
return null;
|
|
116
|
+
if (props.theme !== undefined && !["classic", "ocean", "forest", "mono"].includes(String(props.theme)))
|
|
117
|
+
return null;
|
|
118
|
+
const settings = readCodeSettings(code.meta ?? "");
|
|
119
|
+
return { ...code, meta: writeCodeSettings(code.meta ?? "", { ...settings,
|
|
120
|
+
...(props.width !== undefined ? { width: widthOf(props.width) } : {}),
|
|
121
|
+
...(typeof props.caption === "string" ? { caption: props.caption } : {}),
|
|
122
|
+
...(typeof props.wrap === "boolean" ? { wrap: props.wrap } : {}),
|
|
123
|
+
...(props.theme !== undefined ? { theme: props.theme } : {}),
|
|
124
|
+
}) };
|
|
125
|
+
}
|
|
126
|
+
case "Table": return !inline ? expandTable(node, props) : null;
|
|
127
|
+
default: return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function expandTable(node, props) {
|
|
131
|
+
if (!allowed(props, ["width", "columnWidths", "rowHeight"]))
|
|
132
|
+
return null;
|
|
133
|
+
const children = meaningful(node.children.flatMap(child => child.type === "paragraph" ? child.children : [child]));
|
|
134
|
+
let rows;
|
|
135
|
+
if (children.length === 1 && children[0].type === "table") {
|
|
136
|
+
const table = children[0];
|
|
137
|
+
rows = table.children.map((row, r) => contentElement("TableRow", {}, row.children.map((cell, c) => contentElement("TableCell", {
|
|
138
|
+
header: r === 0 || undefined, align: table.align?.[c] ?? undefined,
|
|
139
|
+
}, [{ type: "paragraph", children: cell.children }]))));
|
|
140
|
+
}
|
|
141
|
+
else if (children.every(child => isContentElement(child) && child.name === "TableRow"))
|
|
142
|
+
rows = children;
|
|
143
|
+
else
|
|
144
|
+
return null;
|
|
145
|
+
if (!rows.length)
|
|
146
|
+
return null;
|
|
147
|
+
const converted = [];
|
|
148
|
+
const occupied = rows.map(() => []);
|
|
149
|
+
const heights = rows.map(row => positive(componentProps(row)?.height) ?? positive(props.rowHeight));
|
|
150
|
+
let columnCount = 0;
|
|
151
|
+
for (const [r, row] of rows.entries()) {
|
|
152
|
+
const rowProps = componentProps(row);
|
|
153
|
+
if (!rowProps || !allowed(rowProps, ["height"]))
|
|
154
|
+
return null;
|
|
155
|
+
const cells = [];
|
|
156
|
+
let c = 0;
|
|
157
|
+
for (const cell of meaningful(row.children.flatMap(child => child.type === "paragraph" ? child.children : [child]))) {
|
|
158
|
+
if (!isContentElement(cell) || cell.name !== "TableCell")
|
|
159
|
+
return null;
|
|
160
|
+
const p = componentProps(cell);
|
|
161
|
+
if (!p || !allowed(p, ["header", "colSpan", "rowSpan", "align", "color", "background"]) || p.header !== undefined && typeof p.header !== "boolean")
|
|
162
|
+
return null;
|
|
163
|
+
while (occupied[r][c])
|
|
164
|
+
c++;
|
|
165
|
+
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)));
|
|
166
|
+
for (let y = r; y < r + rowSpan; y++)
|
|
167
|
+
for (let x = c; x < c + colSpan; x++)
|
|
168
|
+
occupied[y][x] = true;
|
|
169
|
+
const style = textStyleString([p.color && `color: ${p.color}`, p.background && `background-color: ${p.background}`, p.align && `text-align: ${p.align}`].filter(Boolean).join("; "));
|
|
170
|
+
const cellHeights = heights.slice(r, r + rowSpan);
|
|
171
|
+
const height = (rowSpan > 1 || colSpan > 1) && cellHeights.every(value => value !== undefined) ? Math.max(0, cellHeights.reduce((sum, value) => sum + value, 0) - 1) : undefined;
|
|
172
|
+
// The same content viewport preserves merged-cell heights in editor and output.
|
|
173
|
+
cells.push(html(p.header ? "th" : "td", { colSpan: colSpan > 1 ? colSpan : undefined, rowSpan: rowSpan > 1 ? rowSpan : undefined,
|
|
174
|
+
style: [style, height !== undefined ? `--table-cell-height: ${height}px` : ""].filter(Boolean).join("; ") || undefined,
|
|
175
|
+
"data-table-cell-height": height,
|
|
176
|
+
}, [html("span", { "data-table-content": "true" }, cell.children.flatMap(child => child.type === "paragraph" ? child.children : [child]), true)]));
|
|
177
|
+
c += colSpan;
|
|
178
|
+
}
|
|
179
|
+
columnCount = Math.max(columnCount, occupied[r].length);
|
|
180
|
+
converted.push(html("tr", { height: heights[r] }, cells));
|
|
181
|
+
}
|
|
182
|
+
const requested = Array.isArray(props.columnWidths) ? props.columnWidths : typeof props.columnWidths === "string" ? props.columnWidths.split(",").map(Number) : undefined;
|
|
183
|
+
if (requested && (requested.length !== columnCount || requested.some(value => !Number.isFinite(value) || value <= 0)))
|
|
184
|
+
return null;
|
|
185
|
+
const widths = requested ?? Array(columnCount).fill(1), total = widths.reduce((sum, width) => sum + width, 0);
|
|
186
|
+
return html("div", { "data-lettercamp-table": "true", "data-table-width": widthOf(props.width), "data-row-height": positive(props.rowHeight) }, [html("table", {}, [
|
|
187
|
+
html("colgroup", {}, widths.map(width => html("col", { width: `${width / total * 100}%` }))), html("tbody", {}, converted),
|
|
188
|
+
])]);
|
|
189
|
+
}
|
|
190
|
+
export function codeComponent(code) {
|
|
191
|
+
const settings = readCodeSettings(code.meta ?? "");
|
|
192
|
+
const meta = (code.meta ?? "").replace(/(?:^|\s)lc-(width|wrap|theme|caption)=[^\s]*/g, "").trim();
|
|
193
|
+
return contentElement("CodeBlock", {
|
|
194
|
+
width: settings.width === "regular" ? undefined : settings.width,
|
|
195
|
+
wrap: settings.wrap ? undefined : false, theme: settings.theme === "classic" ? undefined : settings.theme,
|
|
196
|
+
caption: settings.caption || undefined,
|
|
197
|
+
}, [{ ...code, meta: meta || null }]);
|
|
198
|
+
}
|
|
199
|
+
// Imported JSX components with matching names retain their original meaning.
|
|
200
|
+
export function importedComponentNames(tree) {
|
|
201
|
+
const names = new Set();
|
|
202
|
+
for (const node of tree.children)
|
|
203
|
+
if (node.type === "mdxjsEsm") {
|
|
204
|
+
for (const statement of node.data?.estree?.body ?? [])
|
|
205
|
+
if (statement.type === "ImportDeclaration") {
|
|
206
|
+
for (const specifier of statement.specifiers)
|
|
207
|
+
names.add(specifier.local.name);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return names;
|
|
211
|
+
}
|
|
212
|
+
// Lower built-ins to the existing HTML contract before highlighting and layout.
|
|
213
|
+
// Generic mdast nodes with hName work in both Markdown and Astro's MDX compiler.
|
|
214
|
+
export function renderContentComponents(tree) {
|
|
215
|
+
const imported = importedComponentNames(tree);
|
|
216
|
+
// Markdown needs a paragraph context to escape question text when exporting
|
|
217
|
+
// legacy HTML. A native summary only accepts phrasing content on the page.
|
|
218
|
+
const childrenOf = (node) => isContentElement(node) && node.name === "summary"
|
|
219
|
+
? node.children.flatMap(child => child.type === "paragraph" ? child.children : [child]) : node.children;
|
|
220
|
+
const visit = (node) => {
|
|
221
|
+
if (isContentComponent(node) && !imported.has(node.name)) {
|
|
222
|
+
const expanded = expandContentComponent(node);
|
|
223
|
+
if (!expanded)
|
|
224
|
+
throw new Error(`Invalid <${node.name}> component. Use supported literal properties and children.`);
|
|
225
|
+
return lower(expanded);
|
|
226
|
+
}
|
|
227
|
+
if ("children" in node)
|
|
228
|
+
node.children = childrenOf(node).map(visit);
|
|
229
|
+
return node;
|
|
230
|
+
};
|
|
231
|
+
const lower = (node) => {
|
|
232
|
+
if (isContentElement(node) && node.name && /^[a-z]/.test(node.name)) {
|
|
233
|
+
const properties = componentProps(node) ?? {};
|
|
234
|
+
// HAST/Astro require a boolean here; an empty JSX string is falsy and
|
|
235
|
+
// would silently remove playback controls from published MDX pages.
|
|
236
|
+
if (Object.hasOwn(properties, "controls"))
|
|
237
|
+
properties.controls = true;
|
|
238
|
+
const children = childrenOf(node).map(child => isContentElement(child) && child.name && /^[a-z]/.test(child.name) ? lower(child) : visit(child));
|
|
239
|
+
return { type: "lettercampElement", data: { hName: node.name, hProperties: properties }, children };
|
|
240
|
+
}
|
|
241
|
+
return visit(node);
|
|
242
|
+
};
|
|
243
|
+
tree.children = tree.children.map(visit);
|
|
244
|
+
}
|
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';
|
|
@@ -9,6 +10,7 @@ import { unified } from '@astrojs/markdown-remark';
|
|
|
9
10
|
import { prepareWorkspace } from './workspace.js';
|
|
10
11
|
import { selectDefaultPort } from './ports.js';
|
|
11
12
|
import { readingTime } from './reading-time.js';
|
|
13
|
+
import { searchContent } from './search-content.js';
|
|
12
14
|
import { mediaDimensions } from './media-dimensions.js';
|
|
13
15
|
import { reactIntegrationDirectory, reactViteConfig } from './react.js';
|
|
14
16
|
import { writeCustomLayout } from './custom-layout.js';
|
|
@@ -49,10 +51,16 @@ export async function startDev(directory, options) {
|
|
|
49
51
|
root: workspace,
|
|
50
52
|
logLevel: 'warn',
|
|
51
53
|
configFile: false,
|
|
54
|
+
base: options.base ?? '/',
|
|
52
55
|
publicDir: path.join(workspace, '.lettercamp-no-public'),
|
|
53
56
|
output: 'static',
|
|
54
|
-
integrations: [mdx(), react()
|
|
55
|
-
|
|
57
|
+
integrations: [mdx(), react(), ...(options.middleware ? [{
|
|
58
|
+
name: 'lettercamp-editor-preview',
|
|
59
|
+
hooks: { 'astro:config:setup': ({ addMiddleware }) => {
|
|
60
|
+
addMiddleware({ entrypoint: options.middleware, order: 'post' });
|
|
61
|
+
} },
|
|
62
|
+
}] : [])],
|
|
63
|
+
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [contentComponents, readingTime, searchContent, codeBlocks, componentBlocks, [mediaDimensions, { root: contentRoot }]], rehypePlugins: [[mediaDimensions, { root: contentRoot }]] }) },
|
|
56
64
|
devToolbar: { enabled: false },
|
|
57
65
|
server: { port, host: options.host, open: options.open },
|
|
58
66
|
vite: {
|
|
@@ -63,6 +71,14 @@ export async function startDev(directory, options) {
|
|
|
63
71
|
name: 'lettercamp-content',
|
|
64
72
|
configureServer(vite) {
|
|
65
73
|
closeVite = () => vite.close();
|
|
74
|
+
if (options.middleware)
|
|
75
|
+
vite.middlewares.use((request, _response, next) => {
|
|
76
|
+
// Astro removes query parameters from prerendered page requests.
|
|
77
|
+
// Preserve the original URL through its dev-adapter locals channel.
|
|
78
|
+
const key = Symbol.for('astro.locals');
|
|
79
|
+
Reflect.set(request, key, { ...Reflect.get(request, key), lettercampRequestUrl: request.url });
|
|
80
|
+
next();
|
|
81
|
+
});
|
|
66
82
|
// Lettercamp has no Socket.IO endpoint. Stray polling clients should
|
|
67
83
|
// receive a 404 without repeatedly triggering Astro's page warnings.
|
|
68
84
|
vite.middlewares.use((request, response, next) => {
|
|
@@ -96,7 +112,8 @@ export async function startDev(directory, options) {
|
|
|
96
112
|
vite.watcher.on('add', updateLayout).on('unlink', updateLayout);
|
|
97
113
|
},
|
|
98
114
|
}],
|
|
99
|
-
server: { strictPort: false, fs: { allow: [workspace, contentRoot, modules, reactIntegrationDirectory
|
|
115
|
+
server: { strictPort: options.strictPort ?? false, fs: { allow: [workspace, contentRoot, modules, reactIntegrationDirectory,
|
|
116
|
+
...(options.middleware ? [path.dirname(options.middleware)] : [])] } },
|
|
100
117
|
},
|
|
101
118
|
});
|
|
102
119
|
process.once('SIGINT', stop);
|
package/dist/media-dimensions.js
CHANGED
|
@@ -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
|
-
|
|
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,24 @@
|
|
|
1
|
+
import { fromHtml } from 'hast-util-from-html';
|
|
2
|
+
// Extract authored text during compilation, never imports, JSX props, or
|
|
3
|
+
// expressions. Dynamic component output is not part of the static index.
|
|
4
|
+
export function searchContent() {
|
|
5
|
+
return (tree, file) => {
|
|
6
|
+
const parts = [];
|
|
7
|
+
function visit(node) {
|
|
8
|
+
if (['script', 'style', 'template'].includes((node.tagName ?? node.name ?? '').toLowerCase()))
|
|
9
|
+
return;
|
|
10
|
+
if (['text', 'inlineCode', 'code'].includes(node.type))
|
|
11
|
+
parts.push(node.value ?? '');
|
|
12
|
+
if (node.type === 'image')
|
|
13
|
+
parts.push(node.alt ?? '');
|
|
14
|
+
if (node.type === 'html')
|
|
15
|
+
visit(fromHtml(node.value ?? '', { fragment: true }));
|
|
16
|
+
for (const child of node.children ?? [])
|
|
17
|
+
visit(child);
|
|
18
|
+
}
|
|
19
|
+
visit(tree);
|
|
20
|
+
const astro = file.data.astro;
|
|
21
|
+
if (astro?.frontmatter)
|
|
22
|
+
astro.frontmatter.searchText = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.12",
|
|
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(', ')}
|
|
21
|
-
<button type="button" class="author-more" aria-haspopup="dialog" data-authors={JSON.stringify(authors)}
|
|
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>}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { sitePath } from '../lib/urls.js';
|
|
3
3
|
import PostCard from './PostCard.astro';
|
|
4
|
+
import PostSearch from './PostSearch.astro';
|
|
4
5
|
import { getPosts, getCategories } from '../lib/posts.js';
|
|
5
6
|
import { readHomeWidthPx } from '../lib/home-layout';
|
|
6
7
|
const { site, category = '' } = Astro.props;
|
|
@@ -16,10 +17,13 @@ const featuredLimit = 9;
|
|
|
16
17
|
<h1>{category || (site.home.title ?? `${site.name} Blog`)}</h1>
|
|
17
18
|
{site.description && <p class="description" style="margin-top: 12px">{site.description}</p>}
|
|
18
19
|
</header>
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
<div class="blog-toolbar">
|
|
21
|
+
{hasCategories && <nav class="category-selector" aria-label="Filter posts by category" hidden={site.home?.showCategories === false}>
|
|
22
|
+
<a href={sitePath("/")} data-category="" aria-current={!category ? "page" : undefined}>All</a>
|
|
23
|
+
{categories.map(item => <a href={item.href} data-category={item.name} aria-current={category === item.name ? "page" : undefined}>{item.name}</a>)}
|
|
24
|
+
</nav>}
|
|
25
|
+
<PostSearch posts={allPosts} />
|
|
26
|
+
</div>
|
|
23
27
|
<p class="sr-only" data-results-count role="status" aria-live="polite">{posts.length} articles</p>
|
|
24
28
|
<section aria-label="Recent articles">
|
|
25
29
|
<h2 class="sr-only">Recent articles</h2>
|
|
@@ -15,7 +15,7 @@ const socials = (site.footer?.socials ?? []).filter(({ href }) => {
|
|
|
15
15
|
<div class="footer-main">
|
|
16
16
|
<div class="footer-brand">
|
|
17
17
|
<a class="brand" href={sitePath("/")} aria-label={`${site.name} home`}>
|
|
18
|
-
<img src={logo} width="24" height="24" alt="" />
|
|
18
|
+
{logo ? <img src={logo} width="24" height="24" alt="" /> : <span>{site.name}</span>}
|
|
19
19
|
</a>
|
|
20
20
|
</div>
|
|
21
21
|
{columns.length > 0 && <div class="footer-columns">
|
|
@@ -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
|
---
|
|
@@ -14,7 +14,7 @@ const logo = site.logo;
|
|
|
14
14
|
<template data-mobile-menu-icons><MobileMenuIcons /></template>
|
|
15
15
|
<div class="site-header-inner">
|
|
16
16
|
<a class="brand" href={sitePath("/")}>
|
|
17
|
-
<img src={logo} width="30" height="30" alt="" />
|
|
17
|
+
{logo && <img src={logo} width="30" height="30" alt="" />}
|
|
18
18
|
<span>{site.name}</span>
|
|
19
19
|
</a>
|
|
20
20
|
{(navLinks.length > 0 || primary) && <div class="header-actions">
|