@zvndev/darkmown 1.2.1 → 1.4.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/AGENTS.md +2 -0
- package/CHANGELOG.md +18 -0
- package/README.md +129 -1
- package/docs/spec-alignment.md +24 -0
- package/package.json +2 -1
- package/site/_/base.skin +14 -0
- package/site/_/nav.wd +1 -0
- package/site/_/scoped-promo.skin +43 -0
- package/site/_/scoped-promo.wd +10 -0
- package/site/_/scoped-stat.skin +32 -0
- package/site/_/scoped-stat.wd +9 -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 +70 -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/site/pages/scoped/index.skin +23 -0
- package/site/pages/scoped/index.wd +47 -0
- package/src/builder.js +239 -10
- package/src/cli.js +51 -7
- package/src/compiler/context.js +10 -1
- package/src/compiler/directives.js +11 -5
- package/src/compiler/includes.js +48 -1
- package/src/compiler/index.js +1 -1
- package/src/compiler/markdown.js +7 -2
- package/src/compiler/page.js +58 -7
- package/src/compiler.js +2 -1
- 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/skin.js +147 -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/compiler/context.js
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
* @property {Set<string>} behaviors Names of framework behavior modules this page
|
|
33
33
|
* needs (`slider` is compile-time only and never appears here; `sortable`,
|
|
34
34
|
* `carousel` each emit a pay-for-what-you-use `/__wd/behaviors/<name>.js`).
|
|
35
|
+
* @property {boolean} hasCode Whether the page has a build-time-highlighted code
|
|
36
|
+
* block, so the framework highlight stylesheet (`/__wd/highlight.css`) is
|
|
37
|
+
* emitted and linked — pay-for-what-you-use, zero JS.
|
|
38
|
+
* @property {Set<string>} scopedSkins Source paths of colocated `.skin` files
|
|
39
|
+
* that opted into scoping (`scoped` first line). The builder emits these with
|
|
40
|
+
* the scope attribute and runs the unused-selector warning against the stamped
|
|
41
|
+
* subtree; the HTML stamp uses them to scope the matching page/include subtree.
|
|
35
42
|
*/
|
|
36
43
|
|
|
37
44
|
/**
|
|
@@ -140,7 +147,9 @@ export function createCompilation() {
|
|
|
140
147
|
scripts: new Set(),
|
|
141
148
|
files: new Map(),
|
|
142
149
|
runtime: false,
|
|
143
|
-
behaviors: new Set()
|
|
150
|
+
behaviors: new Set(),
|
|
151
|
+
hasCode: false,
|
|
152
|
+
scopedSkins: new Set()
|
|
144
153
|
},
|
|
145
154
|
state: new Map(),
|
|
146
155
|
stores: new Set(),
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { compileBody } from "./body.js";
|
|
13
13
|
import { at, createScope, LOOP_META } from "./context.js";
|
|
14
14
|
import { FORMATTERS } from "./format.js";
|
|
15
|
-
import { resolveInclude } from "./includes.js";
|
|
15
|
+
import { resolveInclude, scopedSkinFor } from "./includes.js";
|
|
16
16
|
import {
|
|
17
17
|
escapeHtml,
|
|
18
18
|
getPath,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
stripQuotes,
|
|
27
27
|
validatePath
|
|
28
28
|
} from "./interpolation.js";
|
|
29
|
-
import { compileFile } from "./page.js";
|
|
29
|
+
import { compileFile, stampScope } from "./page.js";
|
|
30
30
|
import { compileComputedExpr, compileWhen, evalPredicate } from "./predicates.js";
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -45,17 +45,23 @@ export function handleInclude(line, ctx, index) {
|
|
|
45
45
|
if (!match) throw new Error(`Malformed @include in ${at(ctx.file, index)}: ${line}`);
|
|
46
46
|
const target = resolveInclude(match[1], ctx.file, ctx.context);
|
|
47
47
|
const args = parseIncludeArgs(match[2] || "", ctx);
|
|
48
|
-
const
|
|
48
|
+
const childScope = createScope(ctx.scope, args);
|
|
49
49
|
const child = compileFile(
|
|
50
50
|
target,
|
|
51
51
|
ctx.context,
|
|
52
52
|
ctx.stack,
|
|
53
|
-
|
|
53
|
+
childScope,
|
|
54
54
|
ctx.comp,
|
|
55
55
|
ctx.sections,
|
|
56
56
|
ctx.loopItem
|
|
57
57
|
);
|
|
58
|
-
|
|
58
|
+
// An include with its OWN scoped colocated skin stamps just its returned
|
|
59
|
+
// subtree — so a scoped `.card` in the include can't collide with a `.card`
|
|
60
|
+
// anywhere else on the page, and the scope never leaks to the include's
|
|
61
|
+
// siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
|
|
62
|
+
// the same path-derived id, so attribute and stylesheet line up.
|
|
63
|
+
const scopedSkin = scopedSkinFor(target, ctx.context);
|
|
64
|
+
return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
/**
|
package/src/compiler/includes.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import { scopeIdFor } from "../skin.js";
|
|
9
10
|
import { pageIncludeExtensions } from "./context.js";
|
|
10
11
|
import { stripQuotes } from "./interpolation.js";
|
|
11
12
|
|
|
@@ -15,6 +16,47 @@ import { stripQuotes } from "./interpolation.js";
|
|
|
15
16
|
* @typedef {import("./context.js").Compilation} Compilation
|
|
16
17
|
*/
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Read a `.skin` file's first meaningful line (skipping `//` line comments,
|
|
21
|
+
* `/* … *\/` block comments, blanks, and decorative dividers — the same skip
|
|
22
|
+
* rules `compileSkin` applies) to decide whether it opts into scoping. A skin
|
|
23
|
+
* scopes its selectors ONLY when that first line is exactly `scoped`; otherwise
|
|
24
|
+
* it is global and untouched. This is the single source for "is this skin
|
|
25
|
+
* scoped + what is its id", so the CSS rewrite (builder) and the HTML stamp
|
|
26
|
+
* (page/include) always agree on both.
|
|
27
|
+
* @param {string} skinPath Absolute path to the colocated `.skin` file.
|
|
28
|
+
* @param {string} cwd Project root, for the path-based scope id.
|
|
29
|
+
* @returns {{ scoped: boolean, scopeId: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function scopeInfoForSkin(skinPath, cwd) {
|
|
32
|
+
const rel = path.relative(cwd, skinPath).replaceAll(path.sep, "/");
|
|
33
|
+
const scopeId = scopeIdFor(rel);
|
|
34
|
+
const src = fs.readFileSync(skinPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
35
|
+
for (const raw of src.split("\n")) {
|
|
36
|
+
const t = raw.trim();
|
|
37
|
+
if (!t || t.startsWith("//") || !/[A-Za-z0-9]/.test(t)) continue;
|
|
38
|
+
return { scoped: t === "scoped", scopeId };
|
|
39
|
+
}
|
|
40
|
+
return { scoped: false, scopeId };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The scope info for a page/include's OWN colocated `.skin` sibling, or null
|
|
45
|
+
* when there is no sibling skin. Used by the HTML stamp sites: a route's own
|
|
46
|
+
* scoped skin → stamp the page body; an include's scoped skin → stamp the
|
|
47
|
+
* returned subtree. Resolution mirrors `collectColocatedAssets` (same stem,
|
|
48
|
+
* `.skin` extension).
|
|
49
|
+
* @param {string} file Absolute path to the page/include source file.
|
|
50
|
+
* @param {Paths} context
|
|
51
|
+
* @returns {{ scoped: boolean, scopeId: string } | null}
|
|
52
|
+
*/
|
|
53
|
+
export function scopedSkinFor(file, context) {
|
|
54
|
+
const ext = path.extname(file);
|
|
55
|
+
const candidate = `${file.slice(0, -ext.length)}.skin`;
|
|
56
|
+
if (!fs.existsSync(candidate)) return null;
|
|
57
|
+
return scopeInfoForSkin(candidate, context.cwd);
|
|
58
|
+
}
|
|
59
|
+
|
|
18
60
|
/**
|
|
19
61
|
* Register a page's colocated `.skin`/`.js` siblings as emitted assets.
|
|
20
62
|
* @param {string} file
|
|
@@ -35,7 +77,12 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
35
77
|
const outputExt = assetExt === ".skin" ? ".css" : ".js";
|
|
36
78
|
const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
|
|
37
79
|
assets.files.set(candidate, publicPath);
|
|
38
|
-
if (assetExt === ".skin")
|
|
80
|
+
if (assetExt === ".skin") {
|
|
81
|
+
assets.skins.add(publicPath);
|
|
82
|
+
// Surface "this skin is scoped" so the warning pass (builder) and the HTML
|
|
83
|
+
// stamp can find the scoped skins among all colocated ones by source path.
|
|
84
|
+
if (scopeInfoForSkin(candidate, context.cwd).scoped) assets.scopedSkins.add(candidate);
|
|
85
|
+
}
|
|
39
86
|
if (assetExt === ".js") assets.scripts.add(publicPath);
|
|
40
87
|
}
|
|
41
88
|
}
|
package/src/compiler/index.js
CHANGED
|
@@ -20,4 +20,4 @@
|
|
|
20
20
|
export { parseFrontmatter } from "./frontmatter.js";
|
|
21
21
|
export { escapeHtml } from "./interpolation.js";
|
|
22
22
|
export { loopKeyOf } from "./loops.js";
|
|
23
|
-
export { compileDocument, compilePage, enhanceImages } from "./page.js";
|
|
23
|
+
export { compileDocument, compilePage, enhanceImages, stampScope } from "./page.js";
|
package/src/compiler/markdown.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
8
|
import MarkdownIt from "markdown-it";
|
|
9
|
+
import { highlightCode } from "../highlight.js";
|
|
9
10
|
import { LOOP_META } from "./context.js";
|
|
10
11
|
import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
|
|
11
12
|
import {
|
|
@@ -21,7 +22,11 @@ import {
|
|
|
21
22
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// Build-time syntax highlighting: highlight.js runs over every fenced code block
|
|
26
|
+
// with a known language. The callback returns escaped token HTML or `""` to let
|
|
27
|
+
// markdown-it render a plain escaped `<code>` (graceful degradation). Both
|
|
28
|
+
// instances share it so `html: false` pages highlight too.
|
|
29
|
+
const md = new MarkdownIt({ html: true, highlight: highlightCode });
|
|
25
30
|
md.use(bindingPlugin);
|
|
26
31
|
md.use(attrsPlugin);
|
|
27
32
|
|
|
@@ -39,7 +44,7 @@ export function selectMd(meta) {
|
|
|
39
44
|
// Frontmatter scalars stay strings (no coercion), so accept both forms.
|
|
40
45
|
if (meta?.html !== false && meta?.html !== "false") return md;
|
|
41
46
|
if (!mdNoHtml) {
|
|
42
|
-
mdNoHtml = new MarkdownIt({ html: false });
|
|
47
|
+
mdNoHtml = new MarkdownIt({ html: false, highlight: highlightCode });
|
|
43
48
|
mdNoHtml.use(bindingPlugin);
|
|
44
49
|
mdNoHtml.use(attrsPlugin);
|
|
45
50
|
}
|
package/src/compiler/page.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { imageSize } from "image-size";
|
|
11
|
+
import { htmlHasHighlight } from "../highlight.js";
|
|
11
12
|
import { compileBody } from "./body.js";
|
|
12
13
|
import { createCompilation, createScope } from "./context.js";
|
|
13
14
|
import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
|
|
14
|
-
import { collectColocatedAssets, scanMarkdownHints } from "./includes.js";
|
|
15
|
+
import { collectColocatedAssets, scanMarkdownHints, scopedSkinFor } from "./includes.js";
|
|
15
16
|
import { escapeHtml } from "./interpolation.js";
|
|
16
17
|
import { selectMd } from "./markdown.js";
|
|
17
18
|
|
|
@@ -29,9 +30,12 @@ import { selectMd } from "./markdown.js";
|
|
|
29
30
|
* Compile a page source file into a full HTML document plus its assets.
|
|
30
31
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
31
32
|
* @param {Paths} context Resolved project paths.
|
|
33
|
+
* @param {{ feed?: { href: string, title: string } }} [options] When a site-wide
|
|
34
|
+
* RSS feed is emitted (the home page set `site_url` and the site has dated
|
|
35
|
+
* posts), `feed` carries its absolute href + title so every page links it.
|
|
32
36
|
* @returns {CompiledPage}
|
|
33
37
|
*/
|
|
34
|
-
export function compilePage(file, context) {
|
|
38
|
+
export function compilePage(file, context, options = {}) {
|
|
35
39
|
const compiled = compileDocument(file, context);
|
|
36
40
|
const title = compiled.meta.title || "Darkmown";
|
|
37
41
|
const description = compiled.meta.description || "";
|
|
@@ -58,9 +62,21 @@ export function compilePage(file, context) {
|
|
|
58
62
|
const descriptionTag = social.length ? `\n ${social.join("\n ")}` : "";
|
|
59
63
|
const favicon =
|
|
60
64
|
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
|
|
61
|
-
|
|
65
|
+
// The framework highlight stylesheet rides ahead of page skins so a project's
|
|
66
|
+
// own `$code-*` tokens / overrides still cascade over it. Pay-for-what-you-use:
|
|
67
|
+
// linked only on pages with a highlighted code block (`hasCode`).
|
|
68
|
+
const highlightLink = compiled.assets.hasCode
|
|
69
|
+
? [`<link rel="stylesheet" href="/__wd/highlight.css">`]
|
|
70
|
+
: [];
|
|
71
|
+
const cssLinks = [...highlightLink, ...compiled.assets.skins]
|
|
62
72
|
.map((href) => `<link rel="stylesheet" href="${href}">`)
|
|
63
73
|
.join("\n");
|
|
74
|
+
// RSS feed discovery: when the site emits an `rss.xml` (home `site_url` set +
|
|
75
|
+
// at least one dated post), every page advertises it so readers/aggregators
|
|
76
|
+
// can autodiscover the feed. Build-time only — no client JS.
|
|
77
|
+
const feedLink = options.feed
|
|
78
|
+
? `\n <link rel="alternate" type="application/rss+xml" title="${escapeHtml(options.feed.title)}" href="${escapeHtml(options.feed.href)}">`
|
|
79
|
+
: "";
|
|
64
80
|
// Pay-for-what-you-use behavior modules (slider is compile-time and never here;
|
|
65
81
|
// sortable/carousel each emit one) load after the runtime — sortable depends on
|
|
66
82
|
// `window.wd` — and before any colocated page script.
|
|
@@ -116,7 +132,14 @@ export function compilePage(file, context) {
|
|
|
116
132
|
? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
|
|
117
133
|
: "";
|
|
118
134
|
|
|
119
|
-
|
|
135
|
+
// A route whose OWN colocated skin opted into scoping stamps the whole page
|
|
136
|
+
// body so its scoped selectors match. Pure compile-time string post-pass (like
|
|
137
|
+
// enhanceImages) — no runtime, no `data-wd-*` marker, so a static page stays
|
|
138
|
+
// static. Include-scoped skins are stamped earlier, in handleInclude.
|
|
139
|
+
const scopedSkin = scopedSkinFor(file, context);
|
|
140
|
+
const stamped =
|
|
141
|
+
scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
|
|
142
|
+
const body = enhanceImages(stamped, context);
|
|
120
143
|
|
|
121
144
|
return {
|
|
122
145
|
meta: compiled.meta,
|
|
@@ -126,7 +149,7 @@ export function compilePage(file, context) {
|
|
|
126
149
|
<meta charset="utf-8">
|
|
127
150
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
128
151
|
<title>${escapeHtml(title)}</title>${descriptionTag}
|
|
129
|
-
<link rel="icon" href="${favicon}"
|
|
152
|
+
<link rel="icon" href="${favicon}">${feedLink}
|
|
130
153
|
${cssLinks}${transitions}${speculation}
|
|
131
154
|
</head>
|
|
132
155
|
<body>
|
|
@@ -176,6 +199,30 @@ export function enhanceImages(html, paths) {
|
|
|
176
199
|
});
|
|
177
200
|
}
|
|
178
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
|
|
204
|
+
* half of compile-time scoped styles, mirroring {@link enhanceImages}: a regex
|
|
205
|
+
* post-pass over assembled HTML, zero runtime JS. Every element start tag that
|
|
206
|
+
* doesn't already carry a scope attribute gets one, so the scoped stylesheet
|
|
207
|
+
* (whose selectors end in `[data-wd-scope="<id>"]`) matches inside this subtree
|
|
208
|
+
* and nowhere else. Void/self-closing tags are stamped too (the attribute is
|
|
209
|
+
* valid on any element). Closing tags, comments, doctype, and the `<!`/`<?`
|
|
210
|
+
* families are left alone. A tag that ALREADY has a `data-wd-scope` (e.g. a
|
|
211
|
+
* nested include with its own scope) keeps its own — first stamp wins.
|
|
212
|
+
* @param {string} html Subtree HTML to scope.
|
|
213
|
+
* @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export function stampScope(html, scopeId) {
|
|
217
|
+
return html.replace(
|
|
218
|
+
/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g,
|
|
219
|
+
(tag, name, attrs, slash) => {
|
|
220
|
+
if (/\bdata-wd-scope\s*=/.test(attrs)) return tag;
|
|
221
|
+
return `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
179
226
|
/**
|
|
180
227
|
* Extract the `src` attribute value from an `<img>` tag, or "" if absent.
|
|
181
228
|
* @param {string} tag
|
|
@@ -253,7 +300,9 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
253
300
|
|
|
254
301
|
if (path.extname(file) === ".md") {
|
|
255
302
|
scanMarkdownHints(body, file, comp);
|
|
256
|
-
|
|
303
|
+
const html = selectMd(meta).render(body, {});
|
|
304
|
+
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
305
|
+
return { meta, html };
|
|
257
306
|
}
|
|
258
307
|
|
|
259
308
|
// Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
|
|
@@ -269,5 +318,7 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
269
318
|
loopItem,
|
|
270
319
|
md: selectMd(meta)
|
|
271
320
|
};
|
|
272
|
-
|
|
321
|
+
const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
|
|
322
|
+
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
323
|
+
return { meta, html };
|
|
273
324
|
}
|
package/src/compiler.js
CHANGED
package/src/dev.js
CHANGED
|
@@ -2,15 +2,44 @@ export const devClientPath = "/__wd/dev-client.js";
|
|
|
2
2
|
export const devEventsPath = "/__wd/dev-events";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Inject the dev-client `<script>` before `</body>` (or append it if absent)
|
|
5
|
+
* Inject the dev-client `<script>` before `</body>` (or append it if absent),
|
|
6
|
+
* plus — for a `draft: true` page — an inline draft-banner script. Both are
|
|
7
|
+
* injected at dev-SERVE time only, so neither ever touches the shipped HTML on
|
|
8
|
+
* disk (a production build excludes drafts entirely; a `build --drafts` staging
|
|
9
|
+
* build emits clean HTML with no banner).
|
|
6
10
|
* @param {string} html Page HTML to augment.
|
|
11
|
+
* @param {{ draft?: boolean }} [options] `draft: true` adds the visible banner.
|
|
7
12
|
* @returns {string}
|
|
8
13
|
*/
|
|
9
|
-
export function injectDevClient(html) {
|
|
10
|
-
const
|
|
14
|
+
export function injectDevClient(html, options = {}) {
|
|
15
|
+
const scripts = [`<script type="module" src="${devClientPath}"></script>`];
|
|
16
|
+
if (options.draft) scripts.push(`<script>${draftBannerScript()}</script>`);
|
|
17
|
+
const block = scripts.join("\n");
|
|
11
18
|
return html.includes("</body>")
|
|
12
|
-
? html.replace("</body>", `${
|
|
13
|
-
: `${html}\n${
|
|
19
|
+
? html.replace("</body>", `${block}\n</body>`)
|
|
20
|
+
: `${html}\n${block}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The inline dev-only draft banner: a fixed bar marking the page as an unpublished
|
|
25
|
+
* draft, so it's unmistakable in `darkmown dev` that this content is excluded from
|
|
26
|
+
* a production build. Inert (`pointer-events:none`) and high-z so it never blocks
|
|
27
|
+
* the page; dev-serve-injected, so it's never in shipped output.
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function draftBannerScript() {
|
|
31
|
+
return `
|
|
32
|
+
const banner = document.createElement("div");
|
|
33
|
+
banner.id = "__wd-draft-banner";
|
|
34
|
+
banner.textContent = "DRAFT — excluded from production build";
|
|
35
|
+
banner.style.cssText = [
|
|
36
|
+
"position:fixed", "top:0", "left:0", "right:0", "z-index:99998",
|
|
37
|
+
"background:#b4541b", "color:#fff8f0", "text-align:center",
|
|
38
|
+
"font:600 12px/2.2 ui-monospace,SFMono-Regular,Menlo,monospace",
|
|
39
|
+
"letter-spacing:.08em", "pointer-events:none"
|
|
40
|
+
].join(";");
|
|
41
|
+
document.addEventListener("DOMContentLoaded", () => document.body.prepend(banner));
|
|
42
|
+
`;
|
|
14
43
|
}
|
|
15
44
|
|
|
16
45
|
/**
|
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;
|