@zvndev/darkmown 1.2.0 → 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 +17 -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/index.js +1 -0
- 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/builder.js
CHANGED
|
@@ -2,15 +2,28 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { discoverApiRoutes } from "./api-runner.js";
|
|
5
|
-
import { compilePage } from "./compiler.js";
|
|
5
|
+
import { compilePage, parseFrontmatter } from "./compiler.js";
|
|
6
6
|
import { createPaths } from "./config.js";
|
|
7
|
+
import {
|
|
8
|
+
absoluteUrl,
|
|
9
|
+
buildRobots,
|
|
10
|
+
buildRss,
|
|
11
|
+
buildSitemap,
|
|
12
|
+
lastmodFor,
|
|
13
|
+
RSS_ITEM_LIMIT,
|
|
14
|
+
rfc822,
|
|
15
|
+
rssDescription
|
|
16
|
+
} from "./feeds.js";
|
|
7
17
|
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
18
|
+
import { HIGHLIGHT_CSS } from "./highlight.js";
|
|
8
19
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
9
20
|
import { compileSkin } from "./skin.js";
|
|
10
21
|
|
|
11
22
|
/**
|
|
12
23
|
* @typedef {import("./config.js").Paths} Paths
|
|
13
24
|
* @typedef {import("./compiler.js").Assets} Assets
|
|
25
|
+
* @typedef {import("./compiler.js").Meta} Meta
|
|
26
|
+
* @typedef {import("./router.js").Route} Route
|
|
14
27
|
*/
|
|
15
28
|
|
|
16
29
|
/**
|
|
@@ -34,15 +47,30 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
34
47
|
* `/api/*` to those same functions and serves everything else as a static asset.
|
|
35
48
|
*
|
|
36
49
|
* @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
|
|
37
|
-
* @param {{ target?: string }} [options] `target:
|
|
38
|
-
*
|
|
50
|
+
* @param {{ target?: string, includeDrafts?: boolean }} [options] `target:
|
|
51
|
+
* "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps `draft:
|
|
52
|
+
* true` pages in the build + feeds (dev / `build --drafts`); default excludes
|
|
53
|
+
* them at route discovery so feeds never see a draft.
|
|
54
|
+
* @returns {{ routes: RouteManifestEntry[], distRoot: string, feeds: { sitemap: number | null, rss: number | null } }}
|
|
39
55
|
*/
|
|
40
56
|
export function buildSite(cwd = process.cwd(), options = {}) {
|
|
41
57
|
const paths = createPaths(cwd);
|
|
42
58
|
fs.rmSync(paths.distRoot, { recursive: true, force: true });
|
|
43
59
|
fs.mkdirSync(paths.distRoot, { recursive: true });
|
|
44
60
|
emitShelfAssets(paths);
|
|
45
|
-
const routes = discoverRoutes(paths.routesRoot);
|
|
61
|
+
const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
|
|
62
|
+
// Site identity lives in the HOME page frontmatter (no config loader): the
|
|
63
|
+
// `site_url` origin triggers + prefixes the feeds, reusing `title`/`description`
|
|
64
|
+
// as the RSS channel fields. Resolved once, up front, so every page can link
|
|
65
|
+
// the feed during the loop.
|
|
66
|
+
const identity = siteIdentity(routes);
|
|
67
|
+
// Advertise the feed in every page's <head> only when rss.xml will actually be
|
|
68
|
+
// emitted: the home set `site_url` AND there is at least one dated post. A
|
|
69
|
+
// stale `<link>` to a missing feed would 404 for aggregators.
|
|
70
|
+
const feedLink =
|
|
71
|
+
identity.siteUrl && hasDatedPost(routes)
|
|
72
|
+
? { href: absoluteUrl(identity.siteUrl, "/rss.xml"), title: identity.title }
|
|
73
|
+
: undefined;
|
|
46
74
|
/** @type {RouteManifestEntry[]} */
|
|
47
75
|
const manifest = [];
|
|
48
76
|
|
|
@@ -53,7 +81,7 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
53
81
|
let notFoundHtml;
|
|
54
82
|
|
|
55
83
|
for (const route of routes) {
|
|
56
|
-
const page = compilePage(route.file, paths);
|
|
84
|
+
const page = compilePage(route.file, paths, { feed: feedLink });
|
|
57
85
|
for (const warning of page.warnings || []) {
|
|
58
86
|
if (warned.has(warning)) continue;
|
|
59
87
|
warned.add(warning);
|
|
@@ -66,6 +94,7 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
66
94
|
emitAssets(page.assets, paths);
|
|
67
95
|
if (page.assets.runtime) emitRuntime(paths);
|
|
68
96
|
emitBehaviors(page.assets, paths);
|
|
97
|
+
if (page.assets.hasCode) emitHighlight(paths);
|
|
69
98
|
const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
|
|
70
99
|
manifest.push({
|
|
71
100
|
route: route.route,
|
|
@@ -95,6 +124,11 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
95
124
|
// one. A catch-all keeps assets and any future path covered.
|
|
96
125
|
fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
|
|
97
126
|
|
|
127
|
+
// Crawler files + feeds. robots.txt is ALWAYS emitted; sitemap.xml + rss.xml
|
|
128
|
+
// need the home `site_url` (else a loud, actionable build hint). Drafts are
|
|
129
|
+
// already filtered out of `routes`, so neither feed can ever see one.
|
|
130
|
+
const feeds = emitFeeds(routes, identity, paths);
|
|
131
|
+
|
|
98
132
|
// Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
|
|
99
133
|
// sees every emitted route/framework file already on disk.
|
|
100
134
|
emitPageAssets(paths);
|
|
@@ -109,7 +143,113 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
109
143
|
emitCloudflareWorker(paths, apiDir, apiRoutes);
|
|
110
144
|
}
|
|
111
145
|
|
|
112
|
-
return { routes: manifest, distRoot: paths.distRoot };
|
|
146
|
+
return { routes: manifest, distRoot: paths.distRoot, feeds };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the site's identity from the HOME route's frontmatter — the single
|
|
151
|
+
* place site-wide metadata lives this cycle (no config loader). `site_url` is
|
|
152
|
+
* the absolute origin (no trailing slash) that triggers and prefixes the feeds;
|
|
153
|
+
* `title`/`description` double as the RSS channel fields. A site with no `/`
|
|
154
|
+
* route, or a home page without `site_url`, yields an empty `siteUrl` and the
|
|
155
|
+
* feeds are skipped (robots still emits).
|
|
156
|
+
* @param {Route[]} routes
|
|
157
|
+
* @returns {{ siteUrl: string, title: string, description: string }}
|
|
158
|
+
*/
|
|
159
|
+
function siteIdentity(routes) {
|
|
160
|
+
const home = routes.find((route) => route.route === "/");
|
|
161
|
+
const meta = home ? home.meta : {};
|
|
162
|
+
const read = (/** @type {string} */ key) =>
|
|
163
|
+
typeof meta[key] === "string" ? String(meta[key]).trim() : "";
|
|
164
|
+
return {
|
|
165
|
+
siteUrl: read("site_url").replace(/\/$/, ""),
|
|
166
|
+
title: read("title") || "Darkmown",
|
|
167
|
+
description: read("description")
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether any emitted route is a "post" — has a frontmatter `date:`. That's the
|
|
173
|
+
* RSS inclusion signal, and (collectively) the trigger for emitting rss.xml at
|
|
174
|
+
* all alongside the home `site_url`.
|
|
175
|
+
* @param {Route[]} routes
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function hasDatedPost(routes) {
|
|
179
|
+
return routes.some((route) => typeof route.meta.date === "string" && route.meta.date.trim());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Emit robots.txt (always) and, when the home page set `site_url`, sitemap.xml
|
|
184
|
+
* (every non-404 route) and rss.xml (dated posts only). Returns the URL/item
|
|
185
|
+
* counts (`null` when a feed was skipped) for the CLI build summary.
|
|
186
|
+
*
|
|
187
|
+
* The sitemap `<lastmod>` for each route is the frontmatter `date:` if present,
|
|
188
|
+
* else `lastmodFor(file)` (git last-commit date → file mtime). RSS items are the
|
|
189
|
+
* routes with a `date:`, newest first, capped at {@link RSS_ITEM_LIMIT}.
|
|
190
|
+
* @param {Route[]} routes Emitted (post-draft-filter) routes.
|
|
191
|
+
* @param {{ siteUrl: string, title: string, description: string }} identity
|
|
192
|
+
* @param {Paths} paths
|
|
193
|
+
* @returns {{ sitemap: number | null, rss: number | null }}
|
|
194
|
+
*/
|
|
195
|
+
function emitFeeds(routes, identity, paths) {
|
|
196
|
+
fs.writeFileSync(path.join(paths.distRoot, "robots.txt"), buildRobots(identity.siteUrl));
|
|
197
|
+
|
|
198
|
+
if (!identity.siteUrl) {
|
|
199
|
+
console.warn(
|
|
200
|
+
"hint: set site_url in site/pages/index frontmatter to emit sitemap.xml + rss.xml " +
|
|
201
|
+
"(e.g. `site_url: https://example.com`). robots.txt was still written."
|
|
202
|
+
);
|
|
203
|
+
return { sitemap: null, rss: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The `/404/` route is never a feed entry — it's not an indexable page and
|
|
207
|
+
// not a post. Filter it out once; both feeds work off the result.
|
|
208
|
+
const feedable = routes.filter((route) => route.route !== "/404/");
|
|
209
|
+
|
|
210
|
+
// Sitemap: one <url> per emitted route (404 excluded above).
|
|
211
|
+
const sitemapEntries = feedable.map((route) => ({
|
|
212
|
+
loc: absoluteUrl(identity.siteUrl, route.route),
|
|
213
|
+
lastmod:
|
|
214
|
+
typeof route.meta.date === "string" && route.meta.date.trim()
|
|
215
|
+
? String(route.meta.date).trim().slice(0, 10)
|
|
216
|
+
: lastmodFor(route.file)
|
|
217
|
+
}));
|
|
218
|
+
fs.writeFileSync(path.join(paths.distRoot, "sitemap.xml"), buildSitemap(sitemapEntries));
|
|
219
|
+
|
|
220
|
+
// RSS: dated posts only, newest first, capped. The `date:` is the "this is a
|
|
221
|
+
// post" signal. With no dated post there is nothing to syndicate, so rss.xml
|
|
222
|
+
// is skipped entirely (and the per-page `<link rel=alternate>` is too) — an
|
|
223
|
+
// empty feed file would just 404-by-content for aggregators.
|
|
224
|
+
const posts = feedable
|
|
225
|
+
.filter((route) => typeof route.meta.date === "string" && route.meta.date.trim())
|
|
226
|
+
.sort((a, b) => String(b.meta.date).localeCompare(String(a.meta.date)))
|
|
227
|
+
.slice(0, RSS_ITEM_LIMIT);
|
|
228
|
+
if (posts.length === 0) return { sitemap: sitemapEntries.length, rss: null };
|
|
229
|
+
|
|
230
|
+
const items = posts.map((route) => {
|
|
231
|
+
const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
|
|
232
|
+
return {
|
|
233
|
+
title: typeof route.meta.title === "string" ? route.meta.title : "Untitled",
|
|
234
|
+
link: absoluteUrl(identity.siteUrl, route.route),
|
|
235
|
+
pubDate: rfc822(String(route.meta.date)),
|
|
236
|
+
description: rssDescription(route.meta, route.file, body)
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
fs.writeFileSync(
|
|
240
|
+
path.join(paths.distRoot, "rss.xml"),
|
|
241
|
+
buildRss(
|
|
242
|
+
{
|
|
243
|
+
title: identity.title,
|
|
244
|
+
description: identity.description,
|
|
245
|
+
siteUrl: identity.siteUrl,
|
|
246
|
+
feedUrl: absoluteUrl(identity.siteUrl, "/rss.xml")
|
|
247
|
+
},
|
|
248
|
+
items
|
|
249
|
+
)
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return { sitemap: sitemapEntries.length, rss: items.length };
|
|
113
253
|
}
|
|
114
254
|
|
|
115
255
|
/**
|
|
@@ -387,6 +527,22 @@ function emitBehaviors(assets, paths) {
|
|
|
387
527
|
}
|
|
388
528
|
}
|
|
389
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Emit the framework syntax-highlighting stylesheet `/__wd/highlight.css`, called
|
|
532
|
+
* only for a page that has a build-time-highlighted code block (`assets.hasCode`).
|
|
533
|
+
* It is CSS only — highlighting is build-time HTML + this stylesheet, with zero
|
|
534
|
+
* runtime JS — and maps highlight.js token classes onto the `$code-*` skin tokens,
|
|
535
|
+
* so code dark-modes for free through `tokens dark` / `:theme`. Idempotent: writing
|
|
536
|
+
* the same content per code-bearing page is harmless and keeps the call site simple.
|
|
537
|
+
* @param {Paths} paths
|
|
538
|
+
* @returns {void}
|
|
539
|
+
*/
|
|
540
|
+
function emitHighlight(paths) {
|
|
541
|
+
const out = path.join(paths.distRoot, "__wd/highlight.css");
|
|
542
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
543
|
+
fs.writeFileSync(out, HIGHLIGHT_CSS);
|
|
544
|
+
}
|
|
545
|
+
|
|
390
546
|
/**
|
|
391
547
|
* @param {Assets} assets
|
|
392
548
|
* @param {Paths} paths
|
package/src/cli.js
CHANGED
|
@@ -6,8 +6,10 @@ import path from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { handleApiRequest } from "./api-runner.js";
|
|
8
8
|
import { buildSite } from "./builder.js";
|
|
9
|
+
import { createPaths } from "./config.js";
|
|
9
10
|
import { DEPLOY_TARGETS, deploy } from "./deploy.js";
|
|
10
11
|
import { devClientPath, devClientScript, devEventsPath, injectDevClient } from "./dev.js";
|
|
12
|
+
import { discoverRoutes, isDraft, outputPathForRoute } from "./router.js";
|
|
11
13
|
import { availableTemplates, initProject } from "./scaffold.js";
|
|
12
14
|
import { contentType, resolvePublicFile, serve } from "./statics.js";
|
|
13
15
|
|
|
@@ -64,8 +66,11 @@ export async function run(argv, env = {}) {
|
|
|
64
66
|
}
|
|
65
67
|
if (command === "build") {
|
|
66
68
|
const target = flagValue(argv, "--target");
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
// `--drafts` includes `draft: true` pages in the build + feeds (staging);
|
|
70
|
+
// the default production build excludes them at route discovery.
|
|
71
|
+
const includeDrafts = argv.includes("--drafts");
|
|
72
|
+
const result = buildSite(cwd, { target, includeDrafts });
|
|
73
|
+
log(buildSummary(result, path.relative(cwd, result.distRoot)));
|
|
69
74
|
if (target === "cloudflare")
|
|
70
75
|
log("Emitted dist/_worker.js for Cloudflare Pages api/* functions");
|
|
71
76
|
return { command: "build" };
|
|
@@ -131,8 +136,10 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
131
136
|
// A broken initial build must NOT crash the process before the HTTP server —
|
|
132
137
|
// and thus the error overlay — comes up. Record the error so new SSE clients
|
|
133
138
|
// see it on connect; the next successful rebuild clears it via `reload`.
|
|
139
|
+
// Dev builds + serves `draft: true` pages (they're work-in-progress you want
|
|
140
|
+
// to preview); the dev client stamps a visible banner so a draft is obvious.
|
|
134
141
|
try {
|
|
135
|
-
buildSite(cwd);
|
|
142
|
+
buildSite(cwd, { includeDrafts: true });
|
|
136
143
|
} catch (err) {
|
|
137
144
|
lastBuildError = (err instanceof Error ? err.message : String(err)).trim();
|
|
138
145
|
error(lastBuildError);
|
|
@@ -144,7 +151,7 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
144
151
|
clearTimeout(timer);
|
|
145
152
|
timer = setTimeout(() => {
|
|
146
153
|
const started = performance.now();
|
|
147
|
-
execFile(process.execPath, [cliPath, "build"], { cwd }, (err, stdout, stderr) => {
|
|
154
|
+
execFile(process.execPath, [cliPath, "build", "--drafts"], { cwd }, (err, stdout, stderr) => {
|
|
148
155
|
if (err) {
|
|
149
156
|
const message = (stderr || stdout || String(err)).trim();
|
|
150
157
|
lastBuildError = message;
|
|
@@ -204,7 +211,7 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
204
211
|
// throw on either path (e.g. a stat/read error) becomes a clean 500.
|
|
205
212
|
handleApiRequest({ apiDir, req, res })
|
|
206
213
|
.then((handled) => {
|
|
207
|
-
if (!handled) serveDev(distRoot, url, res);
|
|
214
|
+
if (!handled) serveDev(distRoot, url, res, draftRoutes(cwd));
|
|
208
215
|
})
|
|
209
216
|
.catch((err) => {
|
|
210
217
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
@@ -262,9 +269,11 @@ function startPreviewServer({ cwd, log, error }) {
|
|
|
262
269
|
* @param {string} distRoot
|
|
263
270
|
* @param {string} url
|
|
264
271
|
* @param {import("node:http").ServerResponse} res
|
|
272
|
+
* @param {Set<string>} draftFiles Absolute dist HTML paths of draft pages — each
|
|
273
|
+
* gets the dev-only draft banner injected (never written to disk).
|
|
265
274
|
* @returns {void}
|
|
266
275
|
*/
|
|
267
|
-
function serveDev(distRoot, url, res) {
|
|
276
|
+
function serveDev(distRoot, url, res, draftFiles) {
|
|
268
277
|
const file = resolvePublicFile(distRoot, url);
|
|
269
278
|
if (!file || !fs.existsSync(file)) {
|
|
270
279
|
serve(distRoot, url, res);
|
|
@@ -274,7 +283,7 @@ function serveDev(distRoot, url, res) {
|
|
|
274
283
|
// Read BEFORE writing headers: if the read throws (e.g. the path is a
|
|
275
284
|
// directory), the handler's catch can still send a clean 500 instead of
|
|
276
285
|
// failing to overwrite an already-sent 200.
|
|
277
|
-
const html = injectDevClient(fs.readFileSync(file, "utf8"));
|
|
286
|
+
const html = injectDevClient(fs.readFileSync(file, "utf8"), { draft: draftFiles.has(file) });
|
|
278
287
|
res.writeHead(200, { "content-type": "text/html" });
|
|
279
288
|
res.end(html);
|
|
280
289
|
return;
|
|
@@ -283,6 +292,24 @@ function serveDev(distRoot, url, res) {
|
|
|
283
292
|
fs.createReadStream(file).pipe(res);
|
|
284
293
|
}
|
|
285
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The set of absolute dist HTML paths that correspond to `draft: true` source
|
|
297
|
+
* pages, so the dev server can banner exactly those. Recomputed per HTML request
|
|
298
|
+
* (cheap, and stays correct as a page gains/loses `draft:` during a dev session).
|
|
299
|
+
* Returns an empty set when the project has no `site/pages` yet.
|
|
300
|
+
* @param {string} cwd Project working directory.
|
|
301
|
+
* @returns {Set<string>}
|
|
302
|
+
*/
|
|
303
|
+
function draftRoutes(cwd) {
|
|
304
|
+
const paths = createPaths(cwd);
|
|
305
|
+
/** @type {Set<string>} */
|
|
306
|
+
const drafts = new Set();
|
|
307
|
+
for (const route of discoverRoutes(paths.routesRoot, { includeDrafts: true })) {
|
|
308
|
+
if (isDraft(route.meta)) drafts.add(outputPathForRoute(paths.distRoot, route.route));
|
|
309
|
+
}
|
|
310
|
+
return drafts;
|
|
311
|
+
}
|
|
312
|
+
|
|
286
313
|
/**
|
|
287
314
|
* @param {Set<import("node:http").ServerResponse>} clients
|
|
288
315
|
* @param {string} message
|
|
@@ -303,6 +330,7 @@ Usage:
|
|
|
303
330
|
darkmown init [dir] [--template <name>] Scaffold a new project from a template
|
|
304
331
|
darkmown dev Start the live compiler dev server
|
|
305
332
|
darkmown build [--target cloudflare] Compile site/pages into dist
|
|
333
|
+
[--drafts] Include draft: true pages (staging)
|
|
306
334
|
darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
|
|
307
335
|
darkmown serve Preview the built dist locally
|
|
308
336
|
darkmown version Print the installed Darkmown version
|
|
@@ -320,6 +348,22 @@ Authoring:
|
|
|
320
348
|
`;
|
|
321
349
|
}
|
|
322
350
|
|
|
351
|
+
/**
|
|
352
|
+
* The one-line `build` summary: route count plus, when the feeds were emitted
|
|
353
|
+
* (home `site_url` set), the sitemap URL and RSS post counts — e.g.
|
|
354
|
+
* `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`. When no
|
|
355
|
+
* `site_url` is set the feed clauses are omitted (a hint was already warned).
|
|
356
|
+
* @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null } }} result
|
|
357
|
+
* @param {string} relativeRoot `dist` relative to cwd, for the message tail.
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
export function buildSummary(result, relativeRoot) {
|
|
361
|
+
const parts = [`Built ${result.routes.length} routes`];
|
|
362
|
+
if (result.feeds.sitemap !== null) parts.push(`sitemap (${result.feeds.sitemap} urls)`);
|
|
363
|
+
if (result.feeds.rss !== null) parts.push(`rss (${result.feeds.rss} posts)`);
|
|
364
|
+
return `${parts.join(", ")} into ${relativeRoot}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
323
367
|
/**
|
|
324
368
|
* @param {string} relativeRoot
|
|
325
369
|
* @returns {string}
|
package/src/compiler/context.js
CHANGED
|
@@ -32,6 +32,9 @@
|
|
|
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.
|
|
35
38
|
*/
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -140,7 +143,8 @@ export function createCompilation() {
|
|
|
140
143
|
scripts: new Set(),
|
|
141
144
|
files: new Map(),
|
|
142
145
|
runtime: false,
|
|
143
|
-
behaviors: new Set()
|
|
146
|
+
behaviors: new Set(),
|
|
147
|
+
hasCode: false
|
|
144
148
|
},
|
|
145
149
|
state: new Map(),
|
|
146
150
|
stores: new Set(),
|
package/src/compiler/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// frontmatter.js `---` block → meta + body, inline scalar/array parsing
|
|
7
7
|
// includes.js @include / @loop-data resolution, colocated assets, .md hints
|
|
8
8
|
// predicates.js where / when / :if / :computed expression compilers
|
|
9
|
+
// format.js { value | pipe } format pipes + list aggregates (value layer)
|
|
9
10
|
// markdown.js markdown-it instances + plugins + prose interpolation
|
|
10
11
|
// loops.js @loop pipeline + row-template initial-paint fill
|
|
11
12
|
// directives.js the handle* family + :button action parser + demo directives
|
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,6 +8,7 @@
|
|
|
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";
|
|
@@ -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.
|
|
@@ -126,7 +142,7 @@ export function compilePage(file, context) {
|
|
|
126
142
|
<meta charset="utf-8">
|
|
127
143
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
128
144
|
<title>${escapeHtml(title)}</title>${descriptionTag}
|
|
129
|
-
<link rel="icon" href="${favicon}"
|
|
145
|
+
<link rel="icon" href="${favicon}">${feedLink}
|
|
130
146
|
${cssLinks}${transitions}${speculation}
|
|
131
147
|
</head>
|
|
132
148
|
<body>
|
|
@@ -253,7 +269,9 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
253
269
|
|
|
254
270
|
if (path.extname(file) === ".md") {
|
|
255
271
|
scanMarkdownHints(body, file, comp);
|
|
256
|
-
|
|
272
|
+
const html = selectMd(meta).render(body, {});
|
|
273
|
+
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
274
|
+
return { meta, html };
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
// Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
|
|
@@ -269,5 +287,7 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
269
287
|
loopItem,
|
|
270
288
|
md: selectMd(meta)
|
|
271
289
|
};
|
|
272
|
-
|
|
290
|
+
const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
|
|
291
|
+
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
292
|
+
return { meta, html };
|
|
273
293
|
}
|
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
|
/**
|