@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/README.md +44 -0
- package/config.schema.json +108 -0
- package/llms.txt +161 -0
- package/package.json +31 -0
- package/src/build.js +206 -0
- package/src/content.js +151 -0
- package/src/excerpt.js +66 -0
- package/src/feeds.js +81 -0
- package/src/index.js +5 -0
- package/src/markdown.js +148 -0
- package/src/templates/archive.js +31 -0
- package/src/templates/base.js +153 -0
- package/src/templates/helpers.js +55 -0
- package/src/templates/index.js +9 -0
- package/src/templates/page.js +20 -0
- package/src/templates/post.js +18 -0
- package/src/templates/tags.js +39 -0
- package/src/themes/chalk/styles.css +720 -0
- package/src/themes/chalk/theme.json +6 -0
- package/src/themes/clay/styles.css +726 -0
- package/src/themes/clay/theme.json +6 -0
- package/src/themes/juniper/styles.css +660 -0
- package/src/themes/juniper/theme.json +6 -0
- package/src/themes/salt/styles.css +728 -0
- package/src/themes/salt/theme.json +6 -0
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";
|
package/src/markdown.js
ADDED
|
@@ -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("&", "&")
|
|
11
|
+
.replaceAll("<", "<")
|
|
12
|
+
.replaceAll(">", ">")
|
|
13
|
+
.replaceAll('"', """);
|
|
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
|
+
}
|