@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.
@@ -0,0 +1,142 @@
1
+ ---
2
+ title: Syntax highlighting — code that dark-modes for free
3
+ description: Build-time syntax highlighting in plain Markdown. Fenced code blocks highlight at compile time (zero client JS) and recolor with your theme through the same tokens dark / :theme system — flip the toggle and watch every language recolor at once.
4
+ image: https://darkmown.com/__wd/media/og-showcase.png
5
+ transitions: true
6
+ ---
7
+
8
+ @include /nav.wd
9
+ @include /base.wd
10
+
11
+ <main class="wide app-highlight">
12
+
13
+ ::: section #highlight
14
+
15
+ :theme
16
+
17
+ ::: hl-head
18
+ ::: eyebrow .hl-eyebrow
19
+ Build-time syntax highlighting
20
+ :::
21
+
22
+ # *Highlighted code*
23
+
24
+ ::: hl-tagline
25
+ Fenced code blocks highlight at **build time** — HTML and CSS only, no client
26
+ JavaScript. Colors come from your skin's `$code-*` tokens, so highlighting
27
+ recolors with the theme through the same `tokens dark` / `:theme` system that
28
+ drives the rest of the page. Flip the toggle and watch every block below recolor
29
+ at once — no toggle script for the code, no flash.
30
+ :::
31
+ :::
32
+
33
+ ::: theme-switch
34
+ Theme
35
+ :button "Auto" -> theme = "auto"
36
+ :button "Light" -> theme = "light"
37
+ :button "Dark" -> theme = "dark"
38
+ :::
39
+
40
+ ::: grid .cols-2 .hl-grid
41
+
42
+ ::: card .hl-panel
43
+ ### JavaScript
44
+
45
+ ```js
46
+ // A tiny reactive island, declared in Markdown.
47
+ const cart = wd.state("cart");
48
+ function total(items) {
49
+ return items.reduce((sum, it) => sum + it.price, 0);
50
+ }
51
+ wd.subscribe("cart", (items) => {
52
+ document.title = `Cart (${items.length})`;
53
+ });
54
+ ```
55
+ :::
56
+
57
+ ::: card .hl-panel
58
+ ### Python
59
+
60
+ ```python
61
+ from dataclasses import dataclass
62
+
63
+ @dataclass
64
+ class Expense:
65
+ merchant: str
66
+ amount: float # in dollars
67
+
68
+ def total(rows: list[Expense]) -> float:
69
+ return sum(r.amount for r in rows)
70
+ ```
71
+ :::
72
+
73
+ ::: card .hl-panel
74
+ ### CSS
75
+
76
+ ```css
77
+ .card {
78
+ background: var(--panel, #fff);
79
+ border: 1px solid var(--line);
80
+ border-radius: 12px;
81
+ padding: 1.4rem;
82
+ }
83
+ ```
84
+ :::
85
+
86
+ ::: card .hl-panel
87
+ ### JSON
88
+
89
+ ```json
90
+ {
91
+ "name": "@zvndev/darkmown",
92
+ "type": "module",
93
+ "zeroJsOnStaticPages": true,
94
+ "runtimeBudgetKb": 8
95
+ }
96
+ ```
97
+ :::
98
+
99
+ ::: card .hl-panel
100
+ ### Bash
101
+
102
+ ```bash
103
+ npx @zvndev/darkmown init --template blog
104
+ cd blog && npx darkmown dev # live reload on :5173
105
+ ```
106
+ :::
107
+
108
+ ::: card .hl-panel
109
+ ### HTML
110
+
111
+ ```html
112
+ <main class="wide">
113
+ <a class="btn primary" href="/docs/">Read the docs</a>
114
+ </main>
115
+ ```
116
+ :::
117
+
118
+ :::
119
+
120
+ ::: hl-note
121
+ **How it works.** The highlighter is [highlight.js](https://highlightjs.org/),
122
+ wired into Markdown's fenced-code path and mapped onto `$code-*` skin tokens.
123
+ The stylesheet is **pay-for-what-you-use** — emitted and linked only on pages
124
+ with a highlighted block. The highlighting itself ships zero JavaScript; the only
125
+ reactive thing on this page is the theme toggle above (so you can demo the
126
+ recolor without changing your OS setting). A fence with an unknown or absent
127
+ language renders as plain escaped `<code>`, and inline `` `code` `` is never
128
+ highlighted.
129
+ :::
130
+
131
+ ::: hl-note .hl-degrade
132
+ **Graceful degradation.** A bare fence (no language) or an unknown one stays
133
+ plain — no highlighting, no error:
134
+
135
+ ```
136
+ just a plain block — no language, so no highlighting
137
+ ```
138
+ :::
139
+
140
+ :::
141
+
142
+ </main>
@@ -2,6 +2,7 @@
2
2
  title: Darkmown — Markdown that runs, not Markdown that needs React
3
3
  description: A live store widget — search filter, add-to-cart, running total — written as one Markdown file that runs. The same outcome in Markdoc needs a React component, hydration, and a build step. Compare them side by side.
4
4
  image: https://darkmown.com/__wd/media/og.png
5
+ site_url: https://darkmown.com
5
6
  transitions: true
6
7
  ---
7
8
 
@@ -0,0 +1,23 @@
1
+ scoped
2
+
3
+ /* Page-scoped skin for this route. The whole page body is stamped, so these
4
+ selectors style only this page's own markup — proving page-level scoping
5
+ alongside the two include-scoped components. A :global() escape hatch shows a
6
+ single rule opting back out to a plain, unscoped selector. */
7
+
8
+ .intro
9
+ margin 2rem 0 3rem
10
+
11
+ .duo
12
+ margin 3rem 0
13
+
14
+ .how
15
+ margin 4rem 0 2rem
16
+
17
+ .how ul
18
+ color $ink-soft
19
+
20
+ /* :global escape — this selector ships UNSCOPED, matching site-wide. */
21
+ :global(.scoped-note)
22
+ font-size .85rem
23
+ color $muted
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: Scoped styles — same class name, zero collision
3
+ description: Two components reuse the class name .card and never clash. Compile-time scoped styles stamp each component's subtree with a path-derived id and rewrite its skin's selectors to match only there. Zero runtime JS — a static page stays static.
4
+ transitions: true
5
+ ---
6
+
7
+ @include /nav.wd
8
+ @include /base.wd
9
+
10
+ <main>
11
+
12
+ <section class="intro">
13
+ <span class="eyebrow">Darkmown 1.4</span>
14
+
15
+ # Scoped styles
16
+
17
+ Two components on this page both call their root element `.card`. In plain CSS the second skin would overwrite the first. Here they coexist: a `.skin` whose first line is `scoped` only ever styles the subtree it ships with. It's pure compile time — a path-derived id stamped onto the HTML and appended to the skin's selectors. No runtime, no class hashing in your markup, no collision.
18
+
19
+ </section>
20
+
21
+ <section class="duo">
22
+
23
+ <div class="grid cols-2">
24
+
25
+ @include /scoped-promo.wd
26
+ @include /scoped-stat.wd
27
+
28
+ </div>
29
+
30
+ </section>
31
+
32
+ <section class="how">
33
+
34
+ ## What actually shipped
35
+
36
+ - The `.card` in the warm promo and the `.card` in the dark stat tile are **different rules**, scoped to different ids.
37
+ - This page also uses a **global** skin (`base.skin`, via `/base.wd`) — `.btn`, `.eyebrow`, `.grid` still work everywhere, unscoped.
38
+ - Design **tokens stay global**: every scoped skin still reads `$accent`, `$warm`, `$radius` from `:root`, so dark mode keeps working for free.
39
+ - A `:global(.x)` selector inside a scoped skin opts a single rule back out.
40
+
41
+ <a class="btn primary" href="/docs/">Read the docs</a>
42
+
43
+ </section>
44
+
45
+ </main>
46
+
47
+ @include /footer.wd
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
- import { compileSkin } from "./skin.js";
20
+ import { compileSkin, scopeIdFor } 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);
@@ -63,9 +91,10 @@ export function buildSite(cwd = process.cwd(), options = {}) {
63
91
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
64
92
  fs.writeFileSync(outFile, page.html);
65
93
  if (route.route === "/404/") notFoundHtml = page.html;
66
- emitAssets(page.assets, paths);
94
+ emitAssets(page.assets, paths, page.html, warned);
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
  /**
@@ -388,22 +528,111 @@ function emitBehaviors(assets, paths) {
388
528
  }
389
529
 
390
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
+
546
+ /**
547
+ * Emit a page's colocated assets. `.skin` files compile through {@link compileSkin}
548
+ * (other files copy verbatim). A skin that opted into scoping (`assets.scopedSkins`)
549
+ * compiles in scoped mode: the scope id is derived here from its PROJECT-RELATIVE
550
+ * PATH — the single coordination point with the HTML stamp, which derives the same
551
+ * id from the same path — and threaded into `compileSkin({ scope })`. The compiler
552
+ * also reports the scoped subject selectors, which we check against the stamped page
553
+ * HTML to warn (never remove) on a scoped selector that matches no element.
391
554
  * @param {Assets} assets
392
555
  * @param {Paths} paths
556
+ * @param {string} pageHtml Assembled page HTML (carries the `data-wd-scope` stamps).
557
+ * @param {Set<string>} warned Cross-route dedupe set for emitted hints.
393
558
  * @returns {void}
394
559
  */
395
- function emitAssets(assets, paths) {
560
+ function emitAssets(assets, paths, pageHtml, warned) {
396
561
  for (const [source, href] of assets.files) {
397
562
  const out = path.join(paths.distRoot, href);
398
563
  fs.mkdirSync(path.dirname(out), { recursive: true });
399
564
  if (source.endsWith(".skin")) {
400
- fs.writeFileSync(out, compileSkin(fs.readFileSync(source, "utf8")));
565
+ const src = fs.readFileSync(source, "utf8");
566
+ if (assets.scopedSkins.has(source)) {
567
+ const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
568
+ const scope = scopeIdFor(rel);
569
+ /** @type {Set<string>} */
570
+ const subjects = new Set();
571
+ fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
572
+ warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
573
+ } else {
574
+ fs.writeFileSync(out, compileSkin(src));
575
+ }
401
576
  } else {
402
577
  fs.copyFileSync(source, out);
403
578
  }
404
579
  }
405
580
  }
406
581
 
582
+ /**
583
+ * Warn (do NOT remove) when a scoped selector's subject never appears in its
584
+ * stamped subtree — a likely typo or stale rule. We only have a class/element/id
585
+ * to look for, so we scan the elements actually stamped with THIS scope id in the
586
+ * assembled page and check each subject against them. A colocated `.js` may add
587
+ * classes at runtime, so this stays advisory: the rule is always emitted. An empty
588
+ * subject token (a bare attribute-selector subject) is unknowable here and skipped.
589
+ * @param {Set<string>} subjects Scoped subject tokens (`.card`, `h2`, `#id`).
590
+ * @param {string} scope Scope id.
591
+ * @param {string} pageHtml Assembled page HTML.
592
+ * @param {string} skinName Skin filename for the message.
593
+ * @param {Set<string>} warned Cross-route dedupe set.
594
+ * @returns {void}
595
+ */
596
+ function warnUnusedScoped(subjects, scope, pageHtml, skinName, warned) {
597
+ // The opening tags carrying this scope id — the only elements the scoped
598
+ // stylesheet can match. One scan, reused for every subject.
599
+ const stamped = pageHtml.match(
600
+ new RegExp(`<[a-zA-Z][\\w-]*[^>]*\\bdata-wd-scope="${scope}"[^>]*>`, "g")
601
+ );
602
+ const tags = stamped ?? [];
603
+ for (const subject of subjects) {
604
+ if (!subject) continue;
605
+ const present = tags.some((tag) => subjectMatchesTag(subject, tag));
606
+ if (present) continue;
607
+ const warning = `scoped selector "${subject}" in ${skinName} matches no element`;
608
+ if (warned.has(warning)) continue;
609
+ warned.add(warning);
610
+ console.warn(`hint: ${warning}`);
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Whether a scoped subject token (`.card` / `#id` / a bare tag like `h2`) could
616
+ * match a single stamped opening tag. Class subjects look in the tag's `class`
617
+ * attribute; id subjects in `id`; a bare tag compares the element name.
618
+ * @param {string} subject
619
+ * @param {string} tag A single opening tag string.
620
+ * @returns {boolean}
621
+ */
622
+ function subjectMatchesTag(subject, tag) {
623
+ if (subject.startsWith(".")) {
624
+ const cls = subject.slice(1);
625
+ const m = tag.match(/\bclass\s*=\s*"([^"]*)"/);
626
+ return m ? m[1].split(/\s+/).includes(cls) : false;
627
+ }
628
+ if (subject.startsWith("#")) {
629
+ const m = tag.match(/\bid\s*=\s*"([^"]*)"/);
630
+ return m ? m[1] === subject.slice(1) : false;
631
+ }
632
+ const name = tag.match(/^<([a-zA-Z][\w-]*)/);
633
+ return name ? name[1].toLowerCase() === subject.toLowerCase() : false;
634
+ }
635
+
407
636
  /**
408
637
  * Copy non-page shelf assets (JSON data, media) into `dist/__wd`.
409
638
  * @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}