@odla-ai/blog 0.0.1

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/src/content.js ADDED
@@ -0,0 +1,151 @@
1
+ // Content loading: markdown files + YAML frontmatter → post/page objects.
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join, basename, relative } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { parse as parseYaml } from "yaml";
6
+
7
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
8
+
9
+ export function parseFrontmatter(raw) {
10
+ const match = FRONTMATTER.exec(raw);
11
+ if (!match) return { data: {}, body: raw };
12
+ return { data: parseYaml(match[1]) ?? {}, body: raw.slice(match[0].length) };
13
+ }
14
+
15
+ // posts/2026/2026-07-03-hello-world.md → slug "hello-world"
16
+ export function slugFromFilename(filename) {
17
+ return basename(filename, ".md").replace(/^\d{4}-\d{2}-\d{2}-/, "");
18
+ }
19
+
20
+ async function markdownFiles(dir) {
21
+ let entries;
22
+ try {
23
+ entries = await readdir(dir, { withFileTypes: true, recursive: true });
24
+ } catch (err) {
25
+ if (err.code === "ENOENT") return [];
26
+ throw err;
27
+ }
28
+ return entries
29
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
30
+ .map((e) => join(e.parentPath, e.name))
31
+ .sort();
32
+ }
33
+
34
+ // slugFormat "clean" (default) strips the date prefix from filenames;
35
+ // "filename" keeps it — for migrations that must preserve existing URLs.
36
+ export async function loadPosts(
37
+ postsDir,
38
+ { now = new Date(), includeDrafts = false, slugFormat = "clean", postsPath = "posts" } = {}
39
+ ) {
40
+ const posts = [];
41
+ const bySlug = new Map();
42
+ for (const file of await markdownFiles(postsDir)) {
43
+ const { data, body } = parseFrontmatter(await readFile(file, "utf8"));
44
+ const slug = slugFormat === "filename" ? basename(file, ".md") : slugFromFilename(file);
45
+ if (bySlug.has(slug)) {
46
+ throw new Error(`Slug collision: "${slug}" from ${file} and ${bySlug.get(slug)}`);
47
+ }
48
+ bySlug.set(slug, file);
49
+ const date = data.date instanceof Date ? data.date : new Date(data.date);
50
+ if (!data.title || Number.isNaN(date.getTime())) {
51
+ throw new Error(`${file}: frontmatter needs a title and a valid date`);
52
+ }
53
+ const draft = data.draft === true;
54
+ const scheduled = date > now;
55
+ if ((draft || scheduled) && !includeDrafts) continue;
56
+ posts.push({
57
+ slug,
58
+ file,
59
+ title: data.title,
60
+ description: data.description ?? "",
61
+ date,
62
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
63
+ image: data.image ?? null,
64
+ draft,
65
+ scheduled,
66
+ body,
67
+ url: `${postsPath}/${slug}/`,
68
+ });
69
+ }
70
+ posts.sort((a, b) => b.date - a.date);
71
+ return posts;
72
+ }
73
+
74
+ // Collections: every directory under <site>/collections/ becomes build-time
75
+ // data handed to all templates as collections.<name> — an array of
76
+ // { slug, file, data, body } with raw frontmatter (schema-free) and the
77
+ // unrendered markdown body. Collections are data, not pages; JS pages and
78
+ // templates decide what (if anything) to render from them.
79
+ export async function loadCollections(collectionsDir) {
80
+ const collections = {};
81
+ let dirs;
82
+ try {
83
+ dirs = (await readdir(collectionsDir, { withFileTypes: true })).filter((e) => e.isDirectory());
84
+ } catch (err) {
85
+ if (err.code === "ENOENT") return collections;
86
+ throw err;
87
+ }
88
+ for (const dir of dirs) {
89
+ const items = [];
90
+ for (const file of await markdownFiles(join(collectionsDir, dir.name))) {
91
+ const { data, body } = parseFrontmatter(await readFile(file, "utf8"));
92
+ items.push({ slug: basename(file, ".md"), file, data, body });
93
+ }
94
+ items.sort((a, b) => a.slug.localeCompare(b.slug));
95
+ collections[dir.name] = items;
96
+ }
97
+ return collections;
98
+ }
99
+
100
+ // JS pages: a page can be a .js module instead of markdown. It exports
101
+ // render(ctx) → string, plus optional title/description. Filenames that
102
+ // still carry an extension after stripping .js (e.g. api/data.json.js)
103
+ // emit raw output at that exact path with no HTML shell.
104
+ export async function loadJsPages(pagesDir) {
105
+ let entries;
106
+ try {
107
+ entries = await readdir(pagesDir, { withFileTypes: true, recursive: true });
108
+ } catch (err) {
109
+ if (err.code === "ENOENT") return [];
110
+ throw err;
111
+ }
112
+ const pages = [];
113
+ for (const entry of entries.filter((e) => e.isFile() && e.name.endsWith(".js")).sort((a, b) => a.name.localeCompare(b.name))) {
114
+ const file = join(entry.parentPath, entry.name);
115
+ const rel = relative(pagesDir, file).slice(0, -3); // strip .js
116
+ const raw = /\.[a-z0-9]+$/i.test(rel);
117
+ const module = await import(pathToFileURL(file).href);
118
+ if (typeof module.render !== "function") {
119
+ throw new Error(`${file}: a JS page must export a render(ctx) function`);
120
+ }
121
+ pages.push({
122
+ file,
123
+ module,
124
+ raw,
125
+ slug: raw ? null : rel,
126
+ outPath: raw ? rel : join(rel, "index.html"),
127
+ title: module.title ?? null,
128
+ description: module.description ?? null,
129
+ url: raw ? rel : `${rel}/`,
130
+ });
131
+ }
132
+ return pages;
133
+ }
134
+
135
+ export async function loadPages(pagesDir) {
136
+ const pages = [];
137
+ for (const file of await markdownFiles(pagesDir)) {
138
+ const { data, body } = parseFrontmatter(await readFile(file, "utf8"));
139
+ const slug = basename(file, ".md");
140
+ pages.push({
141
+ slug,
142
+ file,
143
+ title: data.title ?? slug,
144
+ description: data.description ?? "",
145
+ listTag: data.listTag ?? null,
146
+ body,
147
+ url: `${slug}/`,
148
+ });
149
+ }
150
+ return pages;
151
+ }
package/src/excerpt.js ADDED
@@ -0,0 +1,66 @@
1
+ // Build-time excerpts: truncate rendered post HTML at top-level block
2
+ // boundaries once a word target is reached, appending a continue-reading
3
+ // link. Replaces the fragile client-side truncation JS the Astro site used.
4
+ const VOID_TAGS = new Set([
5
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
6
+ "link", "meta", "param", "source", "track", "wbr",
7
+ ]);
8
+
9
+ function countWords(html) {
10
+ return html.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean).length;
11
+ }
12
+
13
+ // Split well-formed HTML (marked output) into top-level blocks by
14
+ // tracking tag depth. No DOM needed.
15
+ function topLevelBlocks(html) {
16
+ const blocks = [];
17
+ let depth = 0;
18
+ let start = 0;
19
+ const tags = /<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*?>/g;
20
+ let match;
21
+ while ((match = tags.exec(html))) {
22
+ const tag = match[1].toLowerCase();
23
+ const isClose = match[0][1] === "/";
24
+ const isVoid = VOID_TAGS.has(tag) || match[0].endsWith("/>");
25
+ if (isClose) {
26
+ depth--;
27
+ if (depth === 0) {
28
+ blocks.push(html.slice(start, tags.lastIndex));
29
+ start = tags.lastIndex;
30
+ }
31
+ } else if (!isVoid) {
32
+ depth++;
33
+ } else if (depth === 0) {
34
+ blocks.push(html.slice(start, tags.lastIndex));
35
+ start = tags.lastIndex;
36
+ }
37
+ }
38
+ if (start < html.length) blocks.push(html.slice(start));
39
+ return blocks;
40
+ }
41
+
42
+ // Footnote refs point at anchors that only exist on the post page, and the
43
+ // footnotes section belongs to the full post — drop both from excerpts.
44
+ function stripFootnotes(html) {
45
+ return html
46
+ .replace(/<section class="footnotes">[\s\S]*?<\/section>\n?/g, "")
47
+ .replace(/<sup class="footnote-ref"[\s\S]*?<\/sup>/g, "");
48
+ }
49
+
50
+ export function excerpt(html, { minWords = 200, moreUrl } = {}) {
51
+ const clean = stripFootnotes(html);
52
+ if (countWords(clean) <= minWords) {
53
+ return { html: clean, truncated: false };
54
+ }
55
+ const kept = [];
56
+ let words = 0;
57
+ for (const block of topLevelBlocks(clean)) {
58
+ kept.push(block);
59
+ words += countWords(block);
60
+ if (words >= minWords) break;
61
+ }
62
+ const readMore = moreUrl
63
+ ? `\n<p class="read-more"><a href="${moreUrl}">[continue reading]</a></p>`
64
+ : "";
65
+ return { html: kept.join("") + readMore, truncated: true };
66
+ }
package/src/feeds.js ADDED
@@ -0,0 +1,81 @@
1
+ // RSS 2.0 feed + XML sitemap. Hand-rolled: the formats are ~50 lines each
2
+ // and a template dependency would cost more than it saves.
3
+ import { escapeHtml } from "./markdown.js";
4
+
5
+ function absoluteUrl(config, rel) {
6
+ const origin = (config.url ?? "").replace(/\/$/, "");
7
+ return `${origin}${config.basePath ?? "/"}${rel}`;
8
+ }
9
+
10
+ export function rss({ config, posts, limit = 20 }) {
11
+ const items = posts
12
+ .slice(0, limit)
13
+ .map((post) => {
14
+ const url = absoluteUrl(config, post.url);
15
+ return `<item>
16
+ <title>${escapeHtml(post.title)}</title>
17
+ <link>${url}</link>
18
+ <guid>${url}</guid>
19
+ <pubDate>${post.date.toUTCString()}</pubDate>
20
+ ${post.description ? `<description>${escapeHtml(post.description)}</description>` : ""}
21
+ </item>`;
22
+ })
23
+ .join("\n");
24
+ return `<?xml version="1.0" encoding="UTF-8"?>
25
+ <rss version="2.0">
26
+ <channel>
27
+ <title>${escapeHtml(config.title)}</title>
28
+ <link>${absoluteUrl(config, "")}</link>
29
+ <description>${escapeHtml(config.description ?? "")}</description>
30
+ ${items}
31
+ </channel>
32
+ </rss>
33
+ `;
34
+ }
35
+
36
+ export function atom({ config, posts, limit = 20 }) {
37
+ const feedUrl = absoluteUrl(config, "atom.xml");
38
+ const updated = posts.length ? posts[0].date.toISOString() : "1970-01-01T00:00:00.000Z";
39
+ const entries = posts
40
+ .slice(0, limit)
41
+ .map((post) => {
42
+ const url = absoluteUrl(config, post.url);
43
+ return `<entry>
44
+ <title>${escapeHtml(post.title)}</title>
45
+ <link href="${url}"/>
46
+ <id>${url}</id>
47
+ <updated>${post.date.toISOString()}</updated>
48
+ ${post.description ? `<summary>${escapeHtml(post.description)}</summary>` : ""}
49
+ </entry>`;
50
+ })
51
+ .join("\n");
52
+ return `<?xml version="1.0" encoding="utf-8"?>
53
+ <feed xmlns="http://www.w3.org/2005/Atom">
54
+ <title>${escapeHtml(config.title)}</title>
55
+ <link href="${feedUrl}" rel="self"/>
56
+ <link href="${absoluteUrl(config, "")}"/>
57
+ <updated>${updated}</updated>
58
+ <id>${absoluteUrl(config, "")}</id>
59
+ ${config.author ? `<author><name>${escapeHtml(config.author)}</name></author>` : ""}
60
+ ${entries}
61
+ </feed>
62
+ `;
63
+ }
64
+
65
+ export function sitemap({ config, posts, pages }) {
66
+ const urls = [
67
+ "",
68
+ "archive/",
69
+ "tags/",
70
+ ...pages.map((p) => p.url),
71
+ ...posts.map((p) => p.url),
72
+ ];
73
+ const entries = urls
74
+ .map((rel) => `<url><loc>${absoluteUrl(config, rel)}</loc></url>`)
75
+ .join("\n");
76
+ return `<?xml version="1.0" encoding="UTF-8"?>
77
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
78
+ ${entries}
79
+ </urlset>
80
+ `;
81
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { build, resolveThemeFile } from "./build.js";
2
+ export { loadPosts, loadPages, parseFrontmatter, slugFromFilename } from "./content.js";
3
+ export { renderMarkdown, slugify, escapeHtml } from "./markdown.js";
4
+ export { postList, tagList, tagUrl, formatDate, isoDate } from "./templates/helpers.js";
5
+ export { loadCollections, loadJsPages } from "./content.js";
@@ -0,0 +1,148 @@
1
+ // Markdown → HTML: marked with heading anchors, GFM footnotes, and
2
+ // build-time syntax highlighting via highlight.js. Output uses hljs-*
3
+ // classes so each theme colors code for light AND dark mode with plain
4
+ // CSS variables.
5
+ import { Marked } from "marked";
6
+ import hljs from "highlight.js/lib/common";
7
+
8
+ export function escapeHtml(text) {
9
+ return text
10
+ .replaceAll("&", "&amp;")
11
+ .replaceAll("<", "&lt;")
12
+ .replaceAll(">", "&gt;")
13
+ .replaceAll('"', "&quot;");
14
+ }
15
+
16
+ export function slugify(text) {
17
+ return text
18
+ .toLowerCase()
19
+ .replace(/<[^>]*>/g, "")
20
+ .replace(/&#?\w+;/g, "")
21
+ .replace(/[^a-z0-9\s-]/g, "")
22
+ .trim()
23
+ .replace(/\s+/g, "-");
24
+ }
25
+
26
+ // ── GFM footnotes ────────────────────────────────────────────────
27
+ // marked has no built-in footnote support. [^id] references become
28
+ // numbered superscript links; [^id]: definitions collect into a
29
+ // <section class="footnotes"> appended after the content. Numbering
30
+ // follows first-reference order. State is per-parse; renderMarkdown
31
+ // is synchronous, so module-level state is safe.
32
+ let footnoteNumbers = new Map(); // id → number, in first-reference order
33
+ let footnoteBodies = new Map(); // id → rendered inline HTML
34
+
35
+ const footnotes = {
36
+ hooks: {
37
+ preprocess(markdown) {
38
+ footnoteNumbers = new Map();
39
+ footnoteBodies = new Map();
40
+ // Number by first reference (definitions like "[^id]:" excluded).
41
+ for (const match of markdown.matchAll(/\[\^([^\]\s]+)\](?!:)/g)) {
42
+ if (!footnoteNumbers.has(match[1])) {
43
+ footnoteNumbers.set(match[1], footnoteNumbers.size + 1);
44
+ }
45
+ }
46
+ return markdown;
47
+ },
48
+ postprocess(html) {
49
+ const items = [...footnoteNumbers.entries()]
50
+ .filter(([id]) => footnoteBodies.has(id))
51
+ .map(
52
+ ([id, n]) =>
53
+ `<li id="fn-${n}"><p>${footnoteBodies.get(id)} <a href="#fnref-${n}" class="footnote-backref" aria-label="Back to reference ${n}">↩</a></p></li>`
54
+ );
55
+ if (!items.length) return html;
56
+ return `${html}<section class="footnotes"><ol>\n${items.join("\n")}\n</ol></section>\n`;
57
+ },
58
+ },
59
+ extensions: [
60
+ {
61
+ name: "footnoteDef",
62
+ level: "block",
63
+ tokenizer(src) {
64
+ // "[^id]: text" plus continuation lines indented ≥2 spaces.
65
+ const match = /^\[\^([^\]\s]+)\]:[ \t]*([^\n]*(?:\n[ \t]{2,}[^\n]*)*)/.exec(src);
66
+ if (!match) return;
67
+ const text = match[2].replace(/\n[ \t]+/g, " ").trim();
68
+ return {
69
+ type: "footnoteDef",
70
+ raw: match[0],
71
+ id: match[1],
72
+ tokens: this.lexer.inlineTokens(text),
73
+ };
74
+ },
75
+ renderer(token) {
76
+ if (footnoteNumbers.has(token.id)) {
77
+ footnoteBodies.set(token.id, this.parser.parseInline(token.tokens));
78
+ }
79
+ return "";
80
+ },
81
+ },
82
+ {
83
+ name: "footnoteRef",
84
+ level: "inline",
85
+ start(src) {
86
+ return src.indexOf("[^");
87
+ },
88
+ tokenizer(src) {
89
+ const match = /^\[\^([^\]\s]+)\](?!:)/.exec(src);
90
+ if (match && footnoteNumbers.has(match[1])) {
91
+ return { type: "footnoteRef", raw: match[0], id: match[1] };
92
+ }
93
+ },
94
+ renderer(token) {
95
+ const n = footnoteNumbers.get(token.id);
96
+ return `<sup class="footnote-ref" id="fnref-${n}"><a href="#fn-${n}">${n}</a></sup>`;
97
+ },
98
+ },
99
+ ],
100
+ };
101
+
102
+ // Smart typography on plain-text leaves only (never code, never inside
103
+ // tags): -- and --- become em dashes, straight quotes curl. Idempotent —
104
+ // the patterns only match straight/ASCII characters.
105
+ export function smarten(text) {
106
+ return text
107
+ .replace(/---/g, "—")
108
+ .replace(/(^|[^-])--(?!-)/g, "$1—")
109
+ // Opening quotes need BOTH an opening context (start/space/bracket) and
110
+ // word-ish content after — a leaf like `", "` right after a link starts
111
+ // with a CLOSING quote and must not curl open.
112
+ .replace(/(^|[\s([{<‘“])"(?=[\w'‘“])/g, "$1“")
113
+ .replace(/(^|[\s([{<‘“])"$/, "$1“") // leaf ends at an inline element (e.g. a link): opening
114
+ .replace(/"/g, "”")
115
+ .replace(/(^|[\s([{<“])'(?=[\w"“])/g, "$1‘")
116
+ .replace(/(^|[\s([{<“])'$/, "$1‘")
117
+ .replace(/'/g, "’");
118
+ }
119
+
120
+ const marked = new Marked(
121
+ {
122
+ gfm: true,
123
+ renderer: {
124
+ text(token) {
125
+ if (token.tokens) return this.parser.parseInline(token.tokens);
126
+ return token.escaped ? smarten(token.text) : escapeHtml(smarten(token.text));
127
+ },
128
+ code({ text, lang }) {
129
+ const language = lang && hljs.getLanguage(lang) ? lang : null;
130
+ const body = language
131
+ ? hljs.highlight(text, { language }).value
132
+ : escapeHtml(text);
133
+ const cls = language ? `hljs language-${language}` : "hljs";
134
+ return `<pre><code class="${cls}">${body}</code></pre>\n`;
135
+ },
136
+ heading({ tokens, depth }) {
137
+ const text = this.parser.parseInline(tokens);
138
+ const id = slugify(text);
139
+ return `<h${depth} id="${id}">${text}</h${depth}>\n`;
140
+ },
141
+ },
142
+ },
143
+ footnotes
144
+ );
145
+
146
+ export function renderMarkdown(source) {
147
+ return marked.parse(source);
148
+ }
@@ -0,0 +1,31 @@
1
+ // Archive: every post grouped by year, newest year first.
2
+ import { escapeHtml, formatDate, isoDate } from "./helpers.js";
3
+
4
+ export function archive({ config, posts }) {
5
+ const base = config.basePath ?? "/";
6
+ const byYear = new Map();
7
+ for (const post of posts) {
8
+ const year = post.date.getUTCFullYear();
9
+ if (!byYear.has(year)) byYear.set(year, []);
10
+ byYear.get(year).push(post);
11
+ }
12
+ const years = [...byYear.keys()].sort((a, b) => b - a);
13
+ const sections = years
14
+ .map((year) => {
15
+ const items = byYear
16
+ .get(year)
17
+ .map(
18
+ (p) => `<li class="archive-item">
19
+ <time datetime="${isoDate(p.date)}">${formatDate(p.date)}</time>
20
+ <a href="${base}${p.url}">${escapeHtml(p.title)}</a>
21
+ </li>`
22
+ )
23
+ .join("\n");
24
+ return `<section>
25
+ <h2 class="archive-year" id="${year}">${year}</h2>
26
+ <ul class="archive-list">\n${items}\n</ul>
27
+ </section>`;
28
+ })
29
+ .join("\n");
30
+ return `<h1 class="page-title">Archive</h1>\n${sections}`;
31
+ }
@@ -0,0 +1,153 @@
1
+ // Outer HTML shell shared by every page: head (canonical + OpenGraph +
2
+ // Twitter meta, feeds, favicon), sticky nav bar with scroll indicator and
3
+ // light/dark toggle (no-flash), optional recent-posts sidebar, footer.
4
+ // Themes restyle it; they rarely need to replace it.
5
+ import { escapeHtml } from "./helpers.js";
6
+
7
+ const NO_FLASH = `(function(){try{var t=localStorage.getItem("theme");if(t)document.documentElement.setAttribute("data-theme",t)}catch(e){}})();`;
8
+
9
+ const TOGGLE = `document.querySelector(".theme-toggle").addEventListener("click",function(){var r=document.documentElement;var d=r.getAttribute("data-theme")==="dark"||(!r.getAttribute("data-theme")&&matchMedia("(prefers-color-scheme: dark)").matches);var n=d?"light":"dark";r.setAttribute("data-theme",n);try{localStorage.setItem("theme",n)}catch(e){}});
10
+ (function(){var line=document.querySelector(".scroll-line"),dot=document.querySelector(".scroll-dot");if(!line)return;function upd(){var h=document.documentElement.scrollHeight-window.innerHeight;var p=h>0?(window.scrollY/h)*100:0;line.style.width=p+"%";if(dot)dot.style.left=p+"%"}addEventListener("scroll",upd,{passive:true});addEventListener("resize",upd);upd()})();`;
11
+
12
+ const sidebarDate = new Intl.DateTimeFormat("en-US", {
13
+ month: "short",
14
+ day: "numeric",
15
+ year: "numeric",
16
+ timeZone: "UTC",
17
+ });
18
+
19
+ function absolutize(config, url) {
20
+ if (!url) return null;
21
+ if (/^[a-z]+:/.test(url)) return url;
22
+ return (config.url ?? "").replace(/\/$/, "") + (url.startsWith("/") ? url : `${config.basePath ?? "/"}${url}`);
23
+ }
24
+
25
+ // Head metadata for a page. `path` is the site-relative path ("" for home,
26
+ // "posts/slug/"...); `image` an OG image URL (site-relative or absolute).
27
+ function headMeta(config, { fullTitle, description, path, image }) {
28
+ const base = config.basePath ?? "/";
29
+ const pageUrl = absolutize(config, `${base}${path ?? ""}`);
30
+ const ogImage = absolutize(config, image ?? null);
31
+ const lines = [
32
+ `<title>${escapeHtml(fullTitle)}</title>`,
33
+ `<meta name="title" content="${escapeHtml(fullTitle)}">`,
34
+ ];
35
+ if (description) lines.push(`<meta name="description" content="${escapeHtml(description)}">`);
36
+ if (pageUrl) lines.push(`<link rel="canonical" href="${pageUrl}">`);
37
+ if (config.favicon) lines.push(`<link rel="icon" href="${base}${config.favicon}">`);
38
+ lines.push(
39
+ `<link rel="sitemap" href="${base}sitemap.xml">`,
40
+ `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(config.title)}" href="${base}rss.xml">`,
41
+ `<link rel="alternate" type="application/atom+xml" title="${escapeHtml(config.title)}" href="${base}atom.xml">`,
42
+ `<meta property="og:type" content="website">`,
43
+ `<meta property="og:title" content="${escapeHtml(fullTitle)}">`,
44
+ `<meta property="twitter:card" content="summary_large_image">`,
45
+ `<meta property="twitter:title" content="${escapeHtml(fullTitle)}">`
46
+ );
47
+ if (pageUrl) {
48
+ lines.push(
49
+ `<meta property="og:url" content="${pageUrl}">`,
50
+ `<meta property="twitter:url" content="${pageUrl}">`
51
+ );
52
+ }
53
+ if (description) {
54
+ lines.push(
55
+ `<meta property="og:description" content="${escapeHtml(description)}">`,
56
+ `<meta property="twitter:description" content="${escapeHtml(description)}">`
57
+ );
58
+ }
59
+ if (ogImage) {
60
+ lines.push(
61
+ `<meta property="og:image" content="${ogImage}">`,
62
+ `<meta property="twitter:image" content="${ogImage}">`
63
+ );
64
+ }
65
+ return lines.join("\n");
66
+ }
67
+
68
+ // Recent-posts column, enabled by config.sidebarRecentPosts > 0.
69
+ // config.recentPosts is stashed by the build.
70
+ function sidebar(config) {
71
+ const posts = config.recentPosts ?? [];
72
+ if (!posts.length) return "";
73
+ const base = config.basePath ?? "/";
74
+ const links = posts
75
+ .map(
76
+ (p) => `<a href="${base}${p.url}" class="post-link">
77
+ <span class="post-title">${escapeHtml(p.title)}</span>
78
+ <span class="post-date">${sidebarDate.format(p.date)}</span>
79
+ </a>`
80
+ )
81
+ .join("\n");
82
+ return `<aside class="recent-posts-sidebar">
83
+ <h3 class="section-title">Recent Posts</h3>
84
+ <div class="recent-posts">
85
+ ${links}
86
+ </div>
87
+ </aside>`;
88
+ }
89
+
90
+ // Footer: optional links (config.footerLinks) • © year links to the archive,
91
+ // author, optional config.copyright suffix. config.years is computed by the
92
+ // build from the posts.
93
+ function footerContent(config, base) {
94
+ const parts = [];
95
+ for (const link of config.footerLinks ?? []) {
96
+ const href = /^[a-z]+:/.test(link.href) ? link.href : base + link.href.replace(/^\//, "");
97
+ parts.push(`<a href="${href}">${escapeHtml(link.label)}</a>`);
98
+ }
99
+ const years = (config.years ?? [])
100
+ .map((y) => `<a href="${base}archive/#${y}">${y}</a>`)
101
+ .join(", ");
102
+ const author = escapeHtml(config.author ?? config.title);
103
+ const suffix = config.copyright ? ` ${escapeHtml(config.copyright)}` : "";
104
+ parts.push(`<span class="copyright">© ${years ? years + " " : ""}${author}.${suffix}</span>`);
105
+ return parts.join(' <span class="separator">•</span> ');
106
+ }
107
+
108
+ export function base({ config, title, description, path, image, content }) {
109
+ const base = config.basePath ?? "/";
110
+ const fullTitle = title ? `${title} · ${config.title}` : config.title;
111
+ // config.nav overrides the automatic Archive/Tags/pages navigation.
112
+ const nav = config.nav
113
+ ? config.nav.map((n) => ({
114
+ label: n.label,
115
+ href: /^[a-z]+:/.test(n.href) ? n.href : base + n.href.replace(/^\//, ""),
116
+ }))
117
+ : [
118
+ { label: "Archive", href: `${base}archive/` },
119
+ { label: "Tags", href: `${base}tags/` },
120
+ ...(config.pages ?? []).map((p) => ({ label: p.title, href: `${base}${p.url}` })),
121
+ ];
122
+ return `<!doctype html>
123
+ <html lang="${config.lang ?? "en"}">
124
+ <head>
125
+ <meta charset="utf-8">
126
+ <meta name="viewport" content="width=device-width, initial-scale=1">
127
+ ${headMeta(config, { fullTitle, description, path, image })}
128
+ <link rel="stylesheet" href="${base}assets/styles.css">
129
+ <script>${NO_FLASH}</script>
130
+ </head>
131
+ <body${(config.recentPosts ?? []).length ? ' class="has-sidebar"' : ""}>
132
+ <header class="site-header">
133
+ <a class="site-title" href="${base}">${escapeHtml(config.title)}</a>
134
+ <nav class="site-nav">
135
+ ${nav.map((n) => `<a href="${n.href}">${escapeHtml(n.label)}</a>`).join("\n")}
136
+ <button class="theme-toggle" aria-label="Toggle dark mode"><span class="theme-toggle-icon" aria-hidden="true"></span></button>
137
+ </nav>
138
+ <div class="scroll-indicator" aria-hidden="true"><div class="scroll-line"></div><div class="scroll-dot"></div></div>
139
+ </header>
140
+ <div class="content-wrapper">
141
+ <main>
142
+ ${content}
143
+ </main>
144
+ ${sidebar(config)}
145
+ </div>
146
+ <footer class="site-footer">
147
+ <p>${footerContent(config, base)}</p>
148
+ </footer>
149
+ <script>${TOGGLE}</script>
150
+ </body>
151
+ </html>
152
+ `;
153
+ }