@zvndev/darkmown 1.2.1 → 1.3.0
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/CHANGELOG.md +9 -0
- package/README.md +85 -1
- package/docs/spec-alignment.md +8 -0
- package/package.json +2 -1
- package/site/_/base.skin +14 -0
- package/site/_/nav.wd +1 -0
- package/site/pages/blog/drafts-that-never-leak.md +16 -0
- package/site/pages/blog/index.md +13 -0
- package/site/pages/blog/shipping-feeds.md +17 -0
- package/site/pages/blog/unfinished-thoughts.md +13 -0
- package/site/pages/docs/index.wd +47 -0
- package/site/pages/highlight/index.skin +78 -0
- package/site/pages/highlight/index.wd +142 -0
- package/site/pages/index.wd +1 -0
- package/src/builder.js +162 -6
- package/src/cli.js +51 -7
- package/src/compiler/context.js +5 -1
- package/src/compiler/markdown.js +7 -2
- package/src/compiler/page.js +25 -5
- package/src/dev.js +34 -5
- package/src/feeds.js +276 -0
- package/src/highlight.js +126 -0
- package/src/router.js +35 -3
- package/src/templates/blog/site/pages/index.wd +1 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +2 -0
package/src/feeds.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Feeds & crawler files: sitemap.xml, rss.xml, robots.txt. All build-time only
|
|
3
|
+
// (zero runtime, zero client JS) and emitted from the post-draft-filter route
|
|
4
|
+
// list, so a `draft: true` page never reaches a feed. Site identity (origin,
|
|
5
|
+
// channel title/description) lives in the HOME page frontmatter (`site_url` +
|
|
6
|
+
// the existing `title`/`description`) — there is no config loader this cycle.
|
|
7
|
+
//
|
|
8
|
+
// Mirrors `src/highlight.js`: this module owns the strings; `src/builder.js`
|
|
9
|
+
// owns the emission. Pure string builders + a `lastmodFor(file)` with a
|
|
10
|
+
// git → mtime fallback so scaffolded apps / tarballs (no git repo) still date.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {import("./router.js").Route} Route
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A route paired with its source file's last-modified date (ISO yyyy-mm-dd),
|
|
23
|
+
* ready for a sitemap `<lastmod>`.
|
|
24
|
+
* @typedef {object} DatedRoute
|
|
25
|
+
* @property {string} loc Absolute URL (`site_url` + route path).
|
|
26
|
+
* @property {string} lastmod ISO date (`yyyy-mm-dd`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* XML-escape a text value for use inside an element or attribute. Covers the
|
|
31
|
+
* five predefined XML entities — the same set RSS/sitemap consumers expect.
|
|
32
|
+
* @param {string} value
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function escapeXml(value) {
|
|
36
|
+
return String(value)
|
|
37
|
+
.replaceAll("&", "&")
|
|
38
|
+
.replaceAll("<", "<")
|
|
39
|
+
.replaceAll(">", ">")
|
|
40
|
+
.replaceAll('"', """)
|
|
41
|
+
.replaceAll("'", "'");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Join the site origin and a route path into one absolute URL, collapsing the
|
|
46
|
+
* boundary slash so `https://x.com` + `/about/` → `https://x.com/about/` (the
|
|
47
|
+
* home route `/` → `https://x.com/`). `siteUrl` is expected without a trailing
|
|
48
|
+
* slash (the documented contract); a stray one is tolerated.
|
|
49
|
+
* @param {string} siteUrl Absolute origin, e.g. `https://example.com`.
|
|
50
|
+
* @param {string} route Route path (always trailing-slashed, e.g. `/about/`).
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function absoluteUrl(siteUrl, route) {
|
|
54
|
+
return `${siteUrl.replace(/\/$/, "")}${route}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The last-modified date for a source file, as an ISO `yyyy-mm-dd` string.
|
|
59
|
+
* Resolution order: the file's frontmatter `date:` is handled by the caller
|
|
60
|
+
* (it wins); this helper covers the fallback chain — the git last-commit date
|
|
61
|
+
* of the file, else the filesystem mtime. Git is wrapped in try/catch so a
|
|
62
|
+
* scaffolded app or an extracted tarball (no `.git`) degrades to mtime instead
|
|
63
|
+
* of crashing the build.
|
|
64
|
+
* @param {string} file Absolute path to the source file.
|
|
65
|
+
* @returns {string} ISO date (`yyyy-mm-dd`).
|
|
66
|
+
*/
|
|
67
|
+
export function lastmodFor(file) {
|
|
68
|
+
try {
|
|
69
|
+
// `%cs` is the committer date in strict ISO short form (yyyy-mm-dd). `-1`
|
|
70
|
+
// limits to the most recent commit touching this file. A file that is
|
|
71
|
+
// tracked but never committed (or outside a repo) yields empty stdout.
|
|
72
|
+
const out = execFileSync("git", ["log", "-1", "--format=%cs", "--", path.basename(file)], {
|
|
73
|
+
cwd: path.dirname(file),
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
76
|
+
}).trim();
|
|
77
|
+
if (out) return out;
|
|
78
|
+
} catch {
|
|
79
|
+
/* not a git repo, or git unavailable — fall through to mtime */
|
|
80
|
+
}
|
|
81
|
+
return isoDate(fs.statSync(file).mtime);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format a `Date` as an ISO `yyyy-mm-dd` string (sitemap `<lastmod>` form).
|
|
86
|
+
* @param {Date} date
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function isoDate(date) {
|
|
90
|
+
return date.toISOString().slice(0, 10);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const RFC822_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
94
|
+
const RFC822_MONTHS = [
|
|
95
|
+
"Jan",
|
|
96
|
+
"Feb",
|
|
97
|
+
"Mar",
|
|
98
|
+
"Apr",
|
|
99
|
+
"May",
|
|
100
|
+
"Jun",
|
|
101
|
+
"Jul",
|
|
102
|
+
"Aug",
|
|
103
|
+
"Sep",
|
|
104
|
+
"Oct",
|
|
105
|
+
"Nov",
|
|
106
|
+
"Dec"
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format a frontmatter `date:` value as an RFC-822 date for an RSS `<pubDate>`
|
|
111
|
+
* (e.g. `Sat, 28 Jun 2026 00:00:00 GMT`). Accepts anything `new Date` parses
|
|
112
|
+
* (an ISO `yyyy-mm-dd`, a full timestamp); an unparseable value returns "" so
|
|
113
|
+
* the caller can omit the element rather than emit `Invalid Date`.
|
|
114
|
+
* @param {string} value Raw frontmatter date string.
|
|
115
|
+
* @returns {string} RFC-822 date in GMT, or "" when unparseable.
|
|
116
|
+
*/
|
|
117
|
+
export function rfc822(value) {
|
|
118
|
+
const date = new Date(value);
|
|
119
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
120
|
+
const pad = (/** @type {number} */ n) => String(n).padStart(2, "0");
|
|
121
|
+
const day = RFC822_DAYS[date.getUTCDay()];
|
|
122
|
+
const month = RFC822_MONTHS[date.getUTCMonth()];
|
|
123
|
+
return (
|
|
124
|
+
`${day}, ${pad(date.getUTCDate())} ${month} ${date.getUTCFullYear()} ` +
|
|
125
|
+
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} GMT`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read a frontmatter field as a trimmed string, or "" when absent/non-string
|
|
131
|
+
* (arrays, numbers, booleans don't belong in these single-value feed fields).
|
|
132
|
+
* @param {import("./compiler.js").Meta} meta
|
|
133
|
+
* @param {string} key
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
function metaString(meta, key) {
|
|
137
|
+
const value = meta[key];
|
|
138
|
+
return typeof value === "string" ? value.trim() : "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Extract the first paragraph of a plain-`.md` body as a single-line snippet —
|
|
143
|
+
* the RSS description fallback when a `.md` post has neither `excerpt:` nor
|
|
144
|
+
* `description:`. Deliberately dumb: skip leading blank/frontmatter-less lines,
|
|
145
|
+
* take the first run of non-blank prose, strip the most common inline markdown
|
|
146
|
+
* (heading marks, emphasis, inline-code, links → their text), and collapse
|
|
147
|
+
* whitespace. Returns "" when the body is empty or starts with a non-prose
|
|
148
|
+
* block (a fence/heading-only doc) — the caller then falls back further.
|
|
149
|
+
* @param {string} body The post-frontmatter markdown body.
|
|
150
|
+
* @returns {string}
|
|
151
|
+
*/
|
|
152
|
+
export function firstParagraph(body) {
|
|
153
|
+
const lines = body.replace(/\r\n?/g, "\n").split("\n");
|
|
154
|
+
let i = 0;
|
|
155
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
156
|
+
// A heading or fenced block isn't a description paragraph — bail to the
|
|
157
|
+
// caller's next fallback rather than emit "# Title" or code as the summary.
|
|
158
|
+
if (i >= lines.length || /^(#{1,6}\s|```|~~~|>|[-*+]\s|\d+\.\s)/.test(lines[i].trim())) return "";
|
|
159
|
+
/** @type {string[]} */
|
|
160
|
+
const collected = [];
|
|
161
|
+
for (let j = i; j < lines.length && lines[j].trim(); j++) collected.push(lines[j].trim());
|
|
162
|
+
return collected
|
|
163
|
+
.join(" ")
|
|
164
|
+
.replace(/`([^`]+)`/g, "$1") // inline code → its text
|
|
165
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1") // bold
|
|
166
|
+
.replace(/\*([^*]+)\*/g, "$1") // italic
|
|
167
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) → text
|
|
168
|
+
.replace(/\s+/g, " ")
|
|
169
|
+
.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The RSS `<description>` for a post: explicit `excerpt:` wins, else the source
|
|
174
|
+
* `description:`, else (plain `.md` only) the first rendered paragraph. `.wd`
|
|
175
|
+
* bodies are NOT scanned for a first paragraph — stripping reactive/directive
|
|
176
|
+
* markup back to clean prose is not worth the fragility, so a `.wd` post without
|
|
177
|
+
* `excerpt`/`description` simply gets no description.
|
|
178
|
+
* @param {import("./compiler.js").Meta} meta
|
|
179
|
+
* @param {string} file Absolute source path (its extension gates `.md` scanning).
|
|
180
|
+
* @param {string} body The post-frontmatter body.
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
export function rssDescription(meta, file, body) {
|
|
184
|
+
const excerpt = metaString(meta, "excerpt");
|
|
185
|
+
if (excerpt) return excerpt;
|
|
186
|
+
const description = metaString(meta, "description");
|
|
187
|
+
if (description) return description;
|
|
188
|
+
if (path.extname(file) === ".md") return firstParagraph(body);
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build `sitemap.xml` from the emitted (post-draft-filter) routes. One `<url>`
|
|
194
|
+
* per route — reactive pages included (they're indexable HTML) — with `<loc>` =
|
|
195
|
+
* `site_url` + path and `<lastmod>` from the supplied date. The `/404/` route is
|
|
196
|
+
* excluded by the caller before this point. No `<priority>`/`<changefreq>` —
|
|
197
|
+
* Google ignores them and they invite bit-rot.
|
|
198
|
+
* @param {DatedRoute[]} entries Absolute loc + ISO lastmod, in route order.
|
|
199
|
+
* @returns {string} The full XML document (trailing newline).
|
|
200
|
+
*/
|
|
201
|
+
export function buildSitemap(entries) {
|
|
202
|
+
const urls = entries
|
|
203
|
+
.map(
|
|
204
|
+
(entry) =>
|
|
205
|
+
` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n` +
|
|
206
|
+
` <lastmod>${entry.lastmod}</lastmod>\n </url>`
|
|
207
|
+
)
|
|
208
|
+
.join("\n");
|
|
209
|
+
return (
|
|
210
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
211
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
|
212
|
+
`${urls}\n</urlset>\n`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* An RSS item — a dated post in feed order.
|
|
218
|
+
* @typedef {object} RssItem
|
|
219
|
+
* @property {string} title
|
|
220
|
+
* @property {string} link Absolute URL (also used as the `<guid>`).
|
|
221
|
+
* @property {string} pubDate RFC-822 date, or "" to omit.
|
|
222
|
+
* @property {string} description
|
|
223
|
+
*/
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Build `rss.xml` (RSS 2.0) for the dated posts. The caller selects, sorts
|
|
227
|
+
* (date desc), and caps the items; this builder just renders. Channel `<title>`
|
|
228
|
+
* / `<description>` come from the home frontmatter; `<link>` is the site origin
|
|
229
|
+
* and the `<atom:link rel="self">` points at the feed itself (RSS best practice).
|
|
230
|
+
* @param {{ title: string, description: string, siteUrl: string, feedUrl: string }} channel
|
|
231
|
+
* @param {RssItem[]} items
|
|
232
|
+
* @returns {string} The full XML document (trailing newline).
|
|
233
|
+
*/
|
|
234
|
+
export function buildRss(channel, items) {
|
|
235
|
+
const entries = items.map((item) => {
|
|
236
|
+
const parts = [
|
|
237
|
+
` <title>${escapeXml(item.title)}</title>`,
|
|
238
|
+
` <link>${escapeXml(item.link)}</link>`,
|
|
239
|
+
` <guid isPermaLink="true">${escapeXml(item.link)}</guid>`
|
|
240
|
+
];
|
|
241
|
+
if (item.pubDate) parts.push(` <pubDate>${item.pubDate}</pubDate>`);
|
|
242
|
+
if (item.description)
|
|
243
|
+
parts.push(` <description>${escapeXml(item.description)}</description>`);
|
|
244
|
+
return ` <item>\n${parts.join("\n")}\n </item>`;
|
|
245
|
+
});
|
|
246
|
+
const channelLines = [
|
|
247
|
+
` <title>${escapeXml(channel.title)}</title>`,
|
|
248
|
+
` <link>${escapeXml(channel.siteUrl)}</link>`,
|
|
249
|
+
` <description>${escapeXml(channel.description)}</description>`,
|
|
250
|
+
` <atom:link href="${escapeXml(channel.feedUrl)}" rel="self" type="application/rss+xml"/>`,
|
|
251
|
+
...entries
|
|
252
|
+
];
|
|
253
|
+
return (
|
|
254
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
255
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n' +
|
|
256
|
+
" <channel>\n" +
|
|
257
|
+
`${channelLines.join("\n")}\n` +
|
|
258
|
+
" </channel>\n</rss>\n"
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build `robots.txt`. Always allows everything (hidden/draft routes are never
|
|
264
|
+
* built, so there's nothing to `Disallow`). The `Sitemap:` line is included
|
|
265
|
+
* ONLY when `siteUrl` is set — a sitemap URL is only meaningful with an origin.
|
|
266
|
+
* @param {string} [siteUrl] Absolute origin, or "" / undefined to omit the line.
|
|
267
|
+
* @returns {string} The robots.txt body (trailing newline).
|
|
268
|
+
*/
|
|
269
|
+
export function buildRobots(siteUrl) {
|
|
270
|
+
const lines = ["User-agent: *", "Allow: /"];
|
|
271
|
+
if (siteUrl) lines.push(`Sitemap: ${absoluteUrl(siteUrl, "/sitemap.xml")}`);
|
|
272
|
+
return `${lines.join("\n")}\n`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Most-recent-first RSS post cap — a feed is a recent window, not an archive. */
|
|
276
|
+
export const RSS_ITEM_LIMIT = 20;
|
package/src/highlight.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Build-time syntax highlighting: the markdown-it `highlight` callback (wired
|
|
3
|
+
// onto both markdown-it instances in `src/compiler/markdown.js`) plus the
|
|
4
|
+
// framework stylesheet that maps highlight.js token classes onto `$code-*` skin
|
|
5
|
+
// tokens. Highlighting is HTML + CSS only — no runtime, no client JS. The
|
|
6
|
+
// stylesheet is emitted (and linked) ONLY on pages that contain a highlighted
|
|
7
|
+
// code block, mirroring the pay-for-what-you-use behavior modules.
|
|
8
|
+
//
|
|
9
|
+
// Closed-whitelist philosophy: the highlighter is highlight.js and is NOT
|
|
10
|
+
// configurable. A fenced block whose language is unknown or absent is left for
|
|
11
|
+
// markdown-it to render as plain escaped `<code>` (graceful degradation); inline
|
|
12
|
+
// `` `code` `` is never highlighted.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import hljs from "highlight.js";
|
|
16
|
+
|
|
17
|
+
// Stamped onto highlighted output so the compiler can detect (in the rendered
|
|
18
|
+
// HTML) that a block was highlighted and flag `assets.hasCode` for this page —
|
|
19
|
+
// the markdown-it `highlight` callback has no access to the compilation object.
|
|
20
|
+
// Guarantees a marker even for a token-less snippet that produced no `hljs-*`
|
|
21
|
+
// spans, so detection never misses.
|
|
22
|
+
export const HLJS_MARKER = "hljs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* markdown-it `highlight` callback. Returns highlight.js's escaped token HTML for
|
|
26
|
+
* a known language (wrapped so the `hljs` marker is always present), or `""` to
|
|
27
|
+
* defer to markdown-it's own escaping for an unknown/absent language — the
|
|
28
|
+
* graceful-degradation path that renders a plain `<code>` with no highlighting.
|
|
29
|
+
*
|
|
30
|
+
* markdown-it does NOT escape a non-empty return value (it is trusted HTML), so
|
|
31
|
+
* we must return already-escaped content — `hljs.highlight` escapes its output.
|
|
32
|
+
* @param {string} code The fenced block's raw source.
|
|
33
|
+
* @param {string} lang The info string (language) after the opening fence, if any.
|
|
34
|
+
* @returns {string} Highlighted, escaped HTML, or `""` to defer to markdown-it.
|
|
35
|
+
*/
|
|
36
|
+
export function highlightCode(code, lang) {
|
|
37
|
+
// No language, or a language highlight.js doesn't know → plain escaped code.
|
|
38
|
+
if (!lang || !hljs.getLanguage(lang)) return "";
|
|
39
|
+
// `ignoreIllegals` keeps a snippet that doesn't fully parse from throwing — it
|
|
40
|
+
// still highlights best-effort instead of degrading the whole build.
|
|
41
|
+
const { value } = hljs.highlight(code, { language: lang, ignoreIllegals: true });
|
|
42
|
+
return `<span class="${HLJS_MARKER}">${value}</span>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detect whether a rendered HTML fragment contains a highlighted code block, so
|
|
47
|
+
* the page-level `assets.hasCode` flag (and thus the highlight stylesheet) is set
|
|
48
|
+
* only when highlighting actually happened. Matches the `hljs` marker stamped by
|
|
49
|
+
* {@link highlightCode}; a page with only plain/unknown-language code never trips it.
|
|
50
|
+
* @param {string} html Rendered body HTML.
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export function htmlHasHighlight(html) {
|
|
54
|
+
return html.includes(`class="${HLJS_MARKER}"`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The framework highlight stylesheet. Maps highlight.js token classes onto
|
|
58
|
+
// `$code-*` skin tokens via CSS custom properties, so code blocks dark-mode for
|
|
59
|
+
// free through the existing `tokens dark` / `:theme` system with ZERO JS — the
|
|
60
|
+
// same `--code-*` variables flip under the OS dark preference or a manual theme
|
|
61
|
+
// toggle. The `var(..., fallback)` second argument keeps highlighting legible
|
|
62
|
+
// even on a project whose skin omits the `$code-*` tokens entirely.
|
|
63
|
+
//
|
|
64
|
+
// Emitted to `/__wd/highlight.css` and linked only on pages that use it.
|
|
65
|
+
export const HIGHLIGHT_CSS = `/* Darkmown syntax highlighting — highlight.js classes mapped to $code-* skin
|
|
66
|
+
tokens. Dark mode is automatic: the tokens swap under tokens dark / :theme,
|
|
67
|
+
so this stylesheet ships zero JavaScript. */
|
|
68
|
+
.hljs {
|
|
69
|
+
color: var(--code-fg, #e9efe7);
|
|
70
|
+
background: var(--code-bg, #1b2420);
|
|
71
|
+
}
|
|
72
|
+
.hljs-comment,
|
|
73
|
+
.hljs-quote {
|
|
74
|
+
color: var(--code-comment, #7d8a82);
|
|
75
|
+
font-style: italic;
|
|
76
|
+
}
|
|
77
|
+
.hljs-keyword,
|
|
78
|
+
.hljs-selector-tag,
|
|
79
|
+
.hljs-literal,
|
|
80
|
+
.hljs-section,
|
|
81
|
+
.hljs-doctag,
|
|
82
|
+
.hljs-name {
|
|
83
|
+
color: var(--code-keyword, #d39bda);
|
|
84
|
+
}
|
|
85
|
+
.hljs-string,
|
|
86
|
+
.hljs-regexp,
|
|
87
|
+
.hljs-meta .hljs-string,
|
|
88
|
+
.hljs-attr,
|
|
89
|
+
.hljs-selector-attr,
|
|
90
|
+
.hljs-selector-pseudo,
|
|
91
|
+
.hljs-addition {
|
|
92
|
+
color: var(--code-string, #8fd28a);
|
|
93
|
+
}
|
|
94
|
+
.hljs-title,
|
|
95
|
+
.hljs-title.function_,
|
|
96
|
+
.hljs-built_in,
|
|
97
|
+
.hljs-class .hljs-title,
|
|
98
|
+
.hljs-selector-class,
|
|
99
|
+
.hljs-selector-id {
|
|
100
|
+
color: var(--code-function, #82c0e9);
|
|
101
|
+
}
|
|
102
|
+
.hljs-number,
|
|
103
|
+
.hljs-symbol,
|
|
104
|
+
.hljs-bullet,
|
|
105
|
+
.hljs-link,
|
|
106
|
+
.hljs-meta {
|
|
107
|
+
color: var(--code-number, #e3a76f);
|
|
108
|
+
}
|
|
109
|
+
.hljs-variable,
|
|
110
|
+
.hljs-template-variable,
|
|
111
|
+
.hljs-type,
|
|
112
|
+
.hljs-attribute,
|
|
113
|
+
.hljs-params,
|
|
114
|
+
.hljs-tag,
|
|
115
|
+
.hljs-deletion,
|
|
116
|
+
.hljs-punctuation,
|
|
117
|
+
.hljs-operator {
|
|
118
|
+
color: var(--code-punctuation, #b9c4be);
|
|
119
|
+
}
|
|
120
|
+
.hljs-emphasis {
|
|
121
|
+
font-style: italic;
|
|
122
|
+
}
|
|
123
|
+
.hljs-strong {
|
|
124
|
+
font-weight: 600;
|
|
125
|
+
}
|
|
126
|
+
`;
|
package/src/router.js
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { parseFrontmatter } from "./compiler.js";
|
|
3
4
|
import { isHiddenName, pageExtensions } from "./config.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
+
* @typedef {import("./compiler.js").Meta} Meta
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A discovered page route: the source file, its public route path, and its
|
|
12
|
+
* parsed frontmatter. `meta` is read ONCE here — the single source of truth for
|
|
13
|
+
* "is this a draft" and for the downstream sitemap/RSS feeds (which never see a
|
|
14
|
+
* draft, since drafts are filtered out below).
|
|
7
15
|
* @typedef {object} Route
|
|
8
16
|
* @property {string} file Absolute path to the source `.md`/`.wd` file.
|
|
9
17
|
* @property {string} route Public route path (always trailing-slashed, e.g. `/about/`).
|
|
18
|
+
* @property {Meta} meta Parsed frontmatter for the file.
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
/**
|
|
13
22
|
* Walk `routesRoot` and collect page routes, sorted and de-duplicated.
|
|
23
|
+
*
|
|
24
|
+
* Drafts are filtered HERE, at the one route-discovery chokepoint, so every
|
|
25
|
+
* downstream consumer (the build, `routes.json`, the sitemap, the RSS feed)
|
|
26
|
+
* only ever sees the post-filter list — a `draft: true` page can never leak. By
|
|
27
|
+
* default drafts are excluded; pass `{ includeDrafts: true }` (`darkmown dev`
|
|
28
|
+
* and `darkmown build --drafts`) to keep them. This is distinct from, and
|
|
29
|
+
* orthogonal to, the permanent `.`/`-`/`_` filename hiding above: hidden names
|
|
30
|
+
* are private forever; `draft:` is a toggleable, publish-when-ready gate.
|
|
14
31
|
* @param {string} routesRoot Absolute path to `site/pages`.
|
|
32
|
+
* @param {{ includeDrafts?: boolean }} [options]
|
|
15
33
|
* @returns {Route[]}
|
|
16
34
|
*/
|
|
17
|
-
export function discoverRoutes(routesRoot) {
|
|
35
|
+
export function discoverRoutes(routesRoot, options = {}) {
|
|
18
36
|
if (!fs.existsSync(routesRoot)) return [];
|
|
19
37
|
/** @type {Route[]} */
|
|
20
38
|
const routes = [];
|
|
@@ -32,9 +50,12 @@ export function discoverRoutes(routesRoot) {
|
|
|
32
50
|
if (!pageExtensions.has(ext)) continue;
|
|
33
51
|
const base = path.basename(entry.name, ext);
|
|
34
52
|
if (isHiddenName(base)) continue;
|
|
53
|
+
const { meta } = parseFrontmatter(fs.readFileSync(abs, "utf8"), abs);
|
|
54
|
+
if (isDraft(meta) && !options.includeDrafts) continue;
|
|
35
55
|
routes.push({
|
|
36
56
|
file: abs,
|
|
37
|
-
route: routeFromFile(routesRoot, abs)
|
|
57
|
+
route: routeFromFile(routesRoot, abs),
|
|
58
|
+
meta
|
|
38
59
|
});
|
|
39
60
|
}
|
|
40
61
|
}
|
|
@@ -52,6 +73,17 @@ export function discoverRoutes(routesRoot) {
|
|
|
52
73
|
return routes.sort((a, b) => a.route.localeCompare(b.route));
|
|
53
74
|
}
|
|
54
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Whether a page's frontmatter marks it a draft (`draft: true`). The scalar
|
|
78
|
+
* frontmatter parser yields a boolean `true` or the string `"true"`, so accept
|
|
79
|
+
* both; any other value (absent, `false`, `"false"`) is not a draft.
|
|
80
|
+
* @param {Meta} meta
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isDraft(meta) {
|
|
84
|
+
return meta.draft === true || meta.draft === "true";
|
|
85
|
+
}
|
|
86
|
+
|
|
55
87
|
/**
|
|
56
88
|
* Derive a public route path from a source file path.
|
|
57
89
|
* @param {string} routesRoot Absolute path to `site/pages`.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Markdown-native, by default
|
|
3
3
|
description: Every post is a .md file; the index is one .wd loop.
|
|
4
|
+
date: 2026-02-02
|
|
5
|
+
excerpt: Every post is a plain .md file; the index is one .wd loop. The date in each post's frontmatter is what lands it in rss.xml.
|
|
4
6
|
transitions: true
|
|
5
7
|
---
|
|
6
8
|
|