@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/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: "cloudflare"` emits the Pages worker.
38
- * @returns {{ routes: RouteManifestEntry[], distRoot: string }}
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
- const result = buildSite(cwd, { target });
68
- log(`Built ${result.routes.length} routes into ${path.relative(cwd, result.distRoot)}`);
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}
@@ -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(),
@@ -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
@@ -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
- const md = new MarkdownIt({ html: true });
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
  }
@@ -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
- const cssLinks = [...compiled.assets.skins]
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
- return { meta, html: selectMd(meta).render(body, {}) };
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
- return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
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 script = `<script type="module" src="${devClientPath}"></script>`;
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>", `${script}\n</body>`)
13
- : `${html}\n${script}`;
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
  /**