@zvndev/darkmown 2.7.1-next.14.g3f67070 → 2.8.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.
@@ -50,6 +50,7 @@ Darkmown is a Markdown-native framework for mostly-static sites with tiny reacti
50
50
  - [Interactions](#interactions--slider-sortable-carousel)
51
51
  - [Theme toggle — `:theme`](#theme-toggle--theme)
52
52
  - [Scoped styles — `scoped`](#scoped-styles--scoped)
53
+ - [Skin at-rules](#skin-at-rules-font-face-and-keyframes)
53
54
  - [Inline attributes](#inline-attributes)
54
55
  - [Media directives](#media--video-audio-embed)
55
56
  - [Syntax highlighting](#syntax-highlighting)
@@ -301,6 +302,7 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
301
302
  - Otherwise the text is left exactly as written — braces in prose never break a page or pull in the runtime.
302
303
  - Values resolve **inside a link or image destination** too, and every brace in the destination resolves, not just one filling the whole value: `[Docs](/docs/{ region }/)` works.
303
304
  - Values resolve in **raw HTML** as well (attributes and html blocks, HTML-escaped) on an `html: true` page, but those are painted once at build time rather than bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show `` `[x](/p/{ p.slug }/)` `` as syntax.
305
+ - `{ $build.year }`, `{ $build.date }`, and `{ $build.datetime }` fold the build's date at compile time, in prose, raw HTML, and link destinations. They always fold, so a page using only `$build` bindings stays zero-JS. An unknown `$build.*` name stays literal text, and `.md` files are never folded.
304
306
 
305
307
  ### Destinations that bind
306
308
 
@@ -333,6 +335,8 @@ The value being piped must be **in scope** — a loop variable, `:state`/`:store
333
335
 
334
336
  The whitelist: `money`, `number`, `percent`, `round`, `date` / `time` / `datetime` (Intl-backed; `short` / `medium` / `long`), `upper` / `lower` / `capitalize`, `truncate:n` / `trim`, `pluralize:"item"`, and `default:"—"`. The five aggregates double as list pipes — `{ cart | count }`, `{ cart | sum:"price" | money }`, plus `avg` / `min` / `max`. No custom functions, nothing `eval`'d; an unknown pipe is a compile error. There is deliberately no relative "time ago" formatter, so builds stay reproducible.
335
337
 
338
+ Pipes also fold inside **raw HTML** on an `html: true` page: a static value (a collection row, a JSON row, `meta`) in an attribute or html block formats at build time, so a static loop with pipes in raw HTML stays zero-JS. A reactive value there can never fold (raw HTML is a one-time paint), so it is painted once with a build hint, instead of staying silently literal as before 2.8.
339
+
336
340
  ## Frontmatter
337
341
 
338
342
  Frontmatter sits between `---` fences. Values are strings, plus inline arrays:
@@ -348,6 +352,14 @@ tags: [sales, revenue, "q1, q2"]
348
352
 
349
353
  `lang:` sets the document language on `<html lang>` per page (`lang: fr`, `lang: pt-BR`, …); it defaults to `en`. Every page also ships a visually-hidden-until-focused **skip-to-content link** targeting the main landmark — your own `<main>` is reused (an existing `id` wins as the target; an id-less one gets `id="main"`), and a page without one is wrapped in `<main id="main">`. Restyle it via `.wd-skip-link` in a skin. All compile-time — static pages stay zero-JS.
350
354
 
355
+ Three more keys arrived in 2.8:
356
+
357
+ - **`icon: /path`** sets the favicon verbatim (`<link rel="icon">`, with a `type` for `.svg`/`.png`/`.ico`). Without it, the first of `site/pages/icon.svg`, `icon.png`, or `favicon.ico` wins; without either, the built-in mark stays the fallback.
358
+ - **`noindex: true`** builds the page but removes it from discovery: a robots `noindex,nofollow` meta, plus exclusion from `sitemap.xml`, `rss.xml`, and the generated `llms.txt`/`llms-full.txt`. On a collection entry with a `_schema.wd`, declare it as `noindex: boolean?`.
359
+ - **`skins: [/base.skin]`** (an inline array) loads shared `.skin` files for that page only, resolved like `@include` paths; a missing file, a non-`.skin` file, or a path outside `site/pages`/`site/_` is `[WD017]`. A shared skin whose first line is `scoped` scopes to the whole page body, so keep to one scoped skin per page.
360
+
361
+ **Cascade order changed in 2.8.** Stylesheet links emit the highlight sheet first (code pages), then include skins, then `skins:` entries in listed order, and **the page's own colocated `.skin` last**, so the page wins ties at equal specificity. Before 2.8 the page's skin came first; a page rule that unknowingly relied on losing to an include rule now wins.
362
+
351
363
  ## SEO & feeds
352
364
 
353
365
  `darkmown build` writes `sitemap.xml`, `rss.xml`, and `robots.txt` — all build-time, zero client JS. There's no config file: the **home page** frontmatter carries the site's identity. Add `site_url` (absolute origin, no trailing slash) and the sitemap and feed switch on, prefixed with that origin and titled with the home `title`/`description`.
@@ -429,6 +441,8 @@ Add `transitions: true` to a page's frontmatter for **instant, flash-free naviga
429
441
 
430
442
  You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
431
443
 
444
+ **Responsive variants.** Export pre-sized copies next to the source, same directory, same extension, `@<width>` before it (`dr-x@640.webp`, `dr-x@1280.webp`), and a Markdown image gains a `srcset` with the original kept as `src`. Descriptor widths are measured from the file headers (a `@640` file really holding 600px emits `600w`), candidates sort ascending, and the original joins at its intrinsic width. Discovery probes a fixed list of common widths (64 through 3840) rather than reading the directory, so an off-list width is a documented miss, never an error. `sizes` is never emitted; write raw `<img srcset sizes>` on an `html: true` page when you need one. Bound (`![a]({ photo })`), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. Variant files are ordinary colocated assets: they copy to `dist/` and are never routes, and shelf images resolve variants against `/__wd/media/`. A `.md` page gets the hardening but no srcset.
445
+
432
446
  ## Static assets
433
447
 
434
448
  Non-page files live on the include shelf at `site/_/` and are copied into the build untouched:
@@ -820,10 +834,33 @@ compiles to `.card[data-wd-scope="wd-7c21"] { … }`, and the component's markup
820
834
  | A selector rule | Scoped — attribute on the subject, before any `:hover`/`::before` |
821
835
  | `tokens` / `tokens dark` | **Global** on `:root` — `$vars` and dark mode keep working site-wide |
822
836
  | `:global(.toast)` (whole selector) | Opts back out — plain unscoped `.toast { … }` |
837
+ | `@font-face` / `@keyframes` | **Global**, never stamped, same contract as `tokens` |
823
838
  | `page` / `*` / `html` / `body` / `::selection` | Compile error — page-level styles belong in a global skin |
824
839
 
825
840
  Honest limits this release: only **whole-selector** `:global()` (no `.card :global(.x)` yet), and an **unused** scoped selector **warns but is not removed** (a colocated `.js` may add the class). See the side-by-side [Scoped demo](/scoped/).
826
841
 
842
+ ## Skin at-rules: `@font-face` and `@keyframes`
843
+
844
+ Two CSS at-rules compile as self-contained blocks in a `.skin`, because their indented children are font descriptors and keyframe selectors rather than page selectors:
845
+
846
+ ```skin
847
+ @font-face
848
+ font-family "Newsreader"
849
+ src url("/fonts/newsreader.woff2") format("woff2")
850
+ font-weight 200 800
851
+ font-display swap
852
+
853
+ @keyframes fade-in
854
+ from
855
+ opacity 0
856
+ 50%, 75%
857
+ opacity .5
858
+ to
859
+ opacity 1
860
+ ```
861
+
862
+ `@font-face` takes plain declarations, several blocks per skin allowed. `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its declarations one level deeper, and anything else is a compile error, so a keyframe selector is never misread as a declaration. Aliases and `$token` refs work inside both, both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards), and both stay **global** in a `scoped` skin, same as `tokens` blocks.
863
+
827
864
  ## Inline attributes
828
865
 
829
866
  A trailing `{.class .class #id}` attaches classes or an id to the inline element directly before it, most often to style a link as a button without a wrapper:
@@ -834,6 +871,14 @@ A trailing `{.class .class #id}` attaches classes or an id to the inline element
834
871
 
835
872
  The block must follow the element with no space. It works on links, images, and emphasis, and it does **not** attach to inline code. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.` or `#`. This page uses it: a link marked `{.no-prefetch}` is excluded from the `transitions: true` prerender hint.
836
873
 
874
+ **Consecutive raw-HTML lines collapse into one paragraph.** On an `html: true` page, a run of lines that each start with an inline-level or unknown tag (`<a class="card">`, `<picture>`, a custom element) parses as ONE paragraph, so the elements render inline inside a single `<p>` instead of as layout siblings; a card grid collapses to one column this way. Lines starting with a block-level tag (`<div>`, `<section>`, …) never hit this, because CommonMark hoists them into an HTML block. The compiler warns once per run when 2+ consecutive lines of one paragraph each open with a component-looking tag (one carrying a `class` or `id`; classless prose links stay quiet):
875
+
876
+ ```
877
+ site/pages/index.wd:6: 16 sibling HTML block lines will render inline in one <p>; wrap them in a container (e.g. a raw <div>)
878
+ ```
879
+
880
+ The fix is the wrap: put the run inside a raw `<div class="grid">…</div>`. The hint stays silent inside a raw container, inside fenced code, on blank-line-separated lines, on `{ state }`-led lines, and on `.md` pages; a run inside a `:::` directive container DOES warn, because directive containers are not raw HTML.
881
+
837
882
  ## Media — `:video`, `:audio`, `:embed`
838
883
 
839
884
  Three compile-time-only directives (they emit no `data-wd-*`, so a media page stays zero-JS):
@@ -844,7 +889,7 @@ Three compile-time-only directives (they emit no `data-wd-*`, so a media page st
844
889
  :embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
845
890
  ```
846
891
 
847
- `:video`/`:audio` emit a hardened HTML5 player — `preload="metadata"` and `controls` by default, whitelisted flags (`autoplay` implies `muted`), and the `:fetch` URL scheme guard. `:embed` rewrites a YouTube/Vimeo link to its no-cookie/player form in a lazy, responsive `16/9` iframe. See the zero-JS [Media demo](/media/).
892
+ `:video`/`:audio` emit a hardened HTML5 player with `preload="metadata"` and `controls` by default, whitelisted flags (`autoplay` implies `muted`), and the `:fetch` URL scheme guard. `:embed` rewrites a YouTube/Vimeo link to its no-cookie/player form in a lazy, responsive `16/9` iframe, and takes three sizing and permission attributes: `aspect=4:3` swaps the ratio box, `height=400px` pins the height instead (units `px`/`rem`/`em`/`vh`/`vw`/`%`, wins over `aspect=`), and `allow="payment"` replaces the default iframe permissions with tokens from a fixed whitelist (`payment`, `autoplay`, `encrypted-media`, `fullscreen`, `picture-in-picture`, `clipboard-write`, `camera`, `microphone`, `geolocation`). A bad token is `[WD706]`, a bad ratio or length `[WD707]`. See the zero-JS [Media demo](/media/).
848
893
 
849
894
  ## Syntax highlighting
850
895
 
@@ -955,7 +1000,8 @@ The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool
955
1000
  - `darkmown init [dir] [--template <name>]` scaffolds a new site from a template (`starter`, `blog`, `store`, `dashboard`, `landing`). It also writes agent context at the project root: `AGENTS.md` (the full directive reference, copied from the installed package so it can never teach syntax the compiler rejects), a `CLAUDE.md` pointing at it, and a `.gitignore`. Existing files are never overwritten.
956
1001
  - `darkmown dev` — the live compiler: browser reload, an in-browser error overlay, incremental rebuilds, and a local runner for `api/*` functions.
957
1002
  - `darkmown build [--target cloudflare] [--drafts]` — write 100% static output to `dist/` (plus `sitemap.xml` / `rss.xml` / `robots.txt`); `--drafts` includes `draft: true` pages for a staging deploy.
958
- - `darkmown serve` — preview the built `dist/` locally (static only; use `dev` to exercise `api/*`).
1003
+ - `darkmown validate [path]` — compile-check every route under `site/pages/` (drafts included), or one file or directory, without writing `dist/`. Each page reports `compiles.` with its static/reactive status, warnings as bullets, or the coded `WD` error with file, line, and fix hint; the exit code is 1 when any page fails, so it gates CI.
1004
+ - `darkmown serve`: preview the built `dist/` locally. It runs the project's `api/*` functions with the same contract as dev and the production hosts (so `:form` posts and `:fetch` demos against `/api/…` work in the preview), and it compresses text responses, brotli preferred then gzip, negotiated off `Accept-Encoding` with `Vary: Accept-Encoding` set.
959
1005
  - `darkmown catalog [--llms|--llms-full]`: print the `.wd` directive catalog as JSON, a compact cheatsheet for priming an AI model, or the complete reference including every error code (see [AI authoring](#ai-authoring)).
960
1006
  - `darkmown deploy <vercel|cloudflare> [--prod]` — build (target-aware) and deploy through the platform CLI, printing your URL; an unauthenticated CLI surfaces the login to run first.
961
1007
  - `darkmown version` / `darkmown help` — print the installed version, or the full command summary and directive syntax.
@@ -6,6 +6,21 @@
6
6
  // stays free of raw HTML. The copy button is added here instead: progressive
7
7
  // enhancement, so the command is still readable and selectable with JS off.
8
8
 
9
+ // Little Friend click ids for the calls to action. Markdown links take no
10
+ // data attributes, so they are stamped here.
11
+ const ctaIds = [
12
+ ["/docs/", "cta.docs"],
13
+ ["/playground/", "cta.playground"],
14
+ ["/vs/markdoc/", "cta.compare"],
15
+ ["https://github.com/", "cta.github"],
16
+ ["https://vercel.com/new/", "cta.deploy_vercel"]
17
+ ];
18
+ for (const link of document.querySelectorAll(".hero-cta a")) {
19
+ const href = link.getAttribute("href") || "";
20
+ const match = ctaIds.find(([prefix]) => href.startsWith(prefix));
21
+ if (match) link.dataset.lf = match[1];
22
+ }
23
+
9
24
  const installBlock = document.querySelector(".hero pre > code");
10
25
 
11
26
  if (installBlock && navigator.clipboard) {
@@ -21,6 +36,8 @@ if (installBlock && navigator.clipboard) {
21
36
  button.addEventListener("click", async () => {
22
37
  try {
23
38
  await navigator.clipboard.writeText(installBlock.textContent.trim());
39
+ // Little Friend is loaded on production builds only.
40
+ if (typeof window.lf === "function") window.lf("track", "install.copy");
24
41
  button.textContent = "Copied";
25
42
  button.classList.add("is-copied");
26
43
  setTimeout(() => {
package/src/api-runner.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import { appendVary, compressBody, isCompressibleType, negotiateEncoding } from "./statics.js";
4
5
 
5
6
  /**
6
7
  * The local-dev serverless API runner. Gives `darkmown dev` the SAME `/api/*`
@@ -140,11 +141,19 @@ function readBody(req) {
140
141
 
141
142
  /**
142
143
  * Write a Web-standard `Response` back onto a Node `ServerResponse`.
144
+ *
145
+ * `acceptEncoding` is the request's Accept-Encoding header when the caller
146
+ * negotiates compression (the preview server); a JSON/text/svg body is then
147
+ * sent brotli- or gzip-compressed with `Content-Encoding` + `Vary`. When it
148
+ * is omitted (the dev server) the response is written exactly as before — no
149
+ * re-encoding, no extra headers. A handler that already set Content-Encoding
150
+ * is never double-compressed.
143
151
  * @param {Response} response
144
152
  * @param {import("node:http").ServerResponse} res
153
+ * @param {string | string[] | undefined} [acceptEncoding]
145
154
  * @returns {Promise<void>}
146
155
  */
147
- export async function writeWebResponse(response, res) {
156
+ export async function writeWebResponse(response, res, acceptEncoding) {
148
157
  /** @type {Record<string, string | string[]>} */
149
158
  const headers = {};
150
159
  // `Headers.forEach` folds multiple Set-Cookie into one comma-joined value, which
@@ -158,8 +167,19 @@ export async function writeWebResponse(response, res) {
158
167
  const cookies =
159
168
  typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
160
169
  if (cookies.length > 0) headers["set-cookie"] = cookies;
170
+ let buffer = /** @type {Buffer} */ (Buffer.from(await response.arrayBuffer()));
171
+ const encoding = negotiateEncoding(acceptEncoding);
172
+ if (encoding && isCompressibleType(String(headers["content-type"] || ""))) {
173
+ if (!headers["content-encoding"]) {
174
+ headers["content-encoding"] = encoding;
175
+ headers.vary = appendVary(
176
+ /** @type {string | undefined} */ (headers.vary),
177
+ "Accept-Encoding"
178
+ );
179
+ buffer = compressBody(buffer, encoding);
180
+ }
181
+ }
161
182
  res.writeHead(response.status, headers);
162
- const buffer = Buffer.from(await response.arrayBuffer());
163
183
  res.end(buffer);
164
184
  }
165
185
 
@@ -173,9 +193,11 @@ export async function writeWebResponse(response, res) {
173
193
  * @param {string} io.apiDir Absolute path to the project's `api/` directory.
174
194
  * @param {import("node:http").IncomingMessage} io.req
175
195
  * @param {import("node:http").ServerResponse} io.res
196
+ * @param {string | string[] | undefined} [io.acceptEncoding] The request's
197
+ * Accept-Encoding header, when the caller negotiates compression (preview).
176
198
  * @returns {Promise<boolean>}
177
199
  */
178
- export async function handleApiRequest({ apiDir, req, res }) {
200
+ export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
179
201
  const pathname = new URL(req.url || "/", "http://localhost").pathname;
180
202
  const match = matchApiRoute(discoverApiRoutes(apiDir), pathname);
181
203
  if (!match) return false;
@@ -191,11 +213,10 @@ export async function handleApiRequest({ apiDir, req, res }) {
191
213
  "Use: return Response.json({ ... })"
192
214
  );
193
215
  }
194
- await writeWebResponse(response, res);
216
+ await writeWebResponse(response, res, acceptEncoding);
195
217
  } catch (err) {
196
218
  const message = err instanceof Error ? err.message : String(err);
197
- res.writeHead(500, { "content-type": "application/json" });
198
- res.end(JSON.stringify({ ok: false, error: message }));
219
+ await writeWebResponse(Response.json({ ok: false, error: message }, { status: 500 }), res);
199
220
  }
200
221
  return true;
201
222
  }
package/src/builder.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  aiCrawlerPolicy,
12
12
  buildRobots,
13
13
  buildRss,
14
+ isNoindex,
14
15
  lastmodFor,
15
16
  rfc822,
16
17
  rssDescription,
@@ -450,7 +451,9 @@ function breadcrumbTrail(routePath, titles, siteUrl) {
450
451
  */
451
452
  function siteCorpus(routes, identity) {
452
453
  const pages = routes
453
- .filter((route) => route.route !== "/404/")
454
+ // `/404/` is not content, and a `noindex: true` page opted out of being
455
+ // enumerated anywhere — feeds, sitemap, and this corpus alike.
456
+ .filter((route) => route.route !== "/404/" && !isNoindex(route.meta))
454
457
  .map((route) => {
455
458
  const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
456
459
  return {
@@ -898,8 +901,10 @@ function emitFeeds(routes, identity, paths, quietHints = false) {
898
901
  }
899
902
 
900
903
  // The `/404/` route is never a feed entry — it's not an indexable page and
901
- // not a post. Filter it out once; both feeds work off the result.
902
- const feedable = routes.filter((route) => route.route !== "/404/");
904
+ // not a post — and a `noindex: true` route has opted out of both, exactly as
905
+ // it opted out of the index with its robots meta. Filter once; both feeds
906
+ // work off the result.
907
+ const feedable = routes.filter((route) => route.route !== "/404/" && !isNoindex(route.meta));
903
908
 
904
909
  // Sitemap: one <url> per emitted route (404 excluded above). A frontmatter
905
910
  // `date:` wins, but only when it IS a date — an unparseable one warns and
package/src/catalog.js CHANGED
@@ -296,8 +296,9 @@ const DIRECTIVES = [
296
296
  {
297
297
  name: ":embed",
298
298
  kind: "line",
299
- syntax: ':embed <url> [title="…"]',
300
- description: "A lazy, privacy-friendly YouTube/Vimeo/iframe embed.",
299
+ syntax: ':embed <url> [title="…"] [aspect=<w>:<h>] [height=<length>] [allow="token …"]',
300
+ description:
301
+ "A lazy, privacy-friendly YouTube/Vimeo/iframe embed. aspect= swaps the 16:9 ratio box, height= pins the frame, allow= replaces the permission set (whitelisted tokens).",
301
302
  example: EMBED_EXAMPLE,
302
303
  reactive: "static"
303
304
  },
@@ -598,6 +599,24 @@ const FRONTMATTER_KEYS = [
598
599
  description: "Excluded from builds and feeds until published.",
599
600
  example: "draft: true"
600
601
  },
602
+ {
603
+ name: "noindex",
604
+ description:
605
+ "Keep the page out of discovery surfaces: robots noindex meta, sitemap.xml, rss.xml, llms.txt.",
606
+ example: "noindex: true"
607
+ },
608
+ {
609
+ name: "icon",
610
+ description:
611
+ "Favicon path or URL. Overrides the site/pages/icon.svg|png|favicon.ico convention and the built-in mark.",
612
+ example: "icon: /icon.svg"
613
+ },
614
+ {
615
+ name: "skins",
616
+ description:
617
+ "Shared .skin files to load for this page, before its colocated skin (inline array).",
618
+ example: "skins: [/base.skin]"
619
+ },
601
620
  {
602
621
  name: "html",
603
622
  description: "Allow raw HTML in the markdown body (default false).",
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { handleApiRequest } from "./api-runner.js";
8
8
  import { buildSite } from "./builder.js";
9
9
  import { directiveCatalog, llmsFullText, llmsText } from "./catalog.js";
10
+ import { buildCollections } from "./compiler/collections.js";
10
11
  import { createPaths } from "./config.js";
11
12
  import { DEPLOY_TARGETS, deploy } from "./deploy.js";
12
13
  import {
@@ -20,6 +21,7 @@ import {
20
21
  import { discoverRoutes, isDraft, outputPathForRoute } from "./router.js";
21
22
  import { availableTemplates, initProject } from "./scaffold.js";
22
23
  import { contentType, isServableFile, pipeFile, resolvePublicFile, serve } from "./statics.js";
24
+ import { validate as validateSource } from "./tools/validate.js";
23
25
 
24
26
  const cliPath = fileURLToPath(import.meta.url);
25
27
 
@@ -103,6 +105,10 @@ export async function run(argv, env = {}) {
103
105
  log("Emitted dist/_worker.js for Cloudflare Pages api/* functions");
104
106
  return { command: "build" };
105
107
  }
108
+ if (command === "validate") {
109
+ const target = argv.slice(1).find((arg) => !arg.startsWith("-"));
110
+ return runValidate({ cwd, target, log, error });
111
+ }
106
112
  if (command === "dev") {
107
113
  return startDevServer({ cwd, log, warn, error });
108
114
  }
@@ -142,6 +148,159 @@ export class CliError extends Error {
142
148
  }
143
149
  }
144
150
 
151
+ /**
152
+ * File extensions the compiler can read as TEXT during a page compile: page and
153
+ * include sources, colocated `.skin`/`.js` siblings, and `@loop` JSON data.
154
+ * Everything else under `site/` (images, fonts) is either binary or never read
155
+ * by the compiler, so the in-memory copy `validate` runs against skips it.
156
+ * @type {Set<string>}
157
+ */
158
+ const VALIDATE_SOURCE_EXTENSIONS = new Set([".md", ".wd", ".skin", ".js", ".json"]);
159
+
160
+ /**
161
+ * `darkmown validate [path]`: compile-check pages without writing `dist`.
162
+ *
163
+ * Every check goes through the SAME validator the agent tools surface exposes
164
+ * (`src/tools/validate.js`), against an in-memory copy of `site/` — so this
165
+ * command and a tool-loop `validate` call can never disagree about what
166
+ * compiles, and the headline vocabulary (`compiles. …`, `compiles with N
167
+ * warnings. …` plus a bullet per warning) is shared byte-for-byte. Collections
168
+ * are resolved project-wide (validating `_schema.wd` entries first), exactly as
169
+ * the build resolves them, so a `@loop blog into post` page checks clean.
170
+ *
171
+ * Drafts are included: `darkmown dev` serves them, so a broken draft is a
172
+ * broken project even though a production build skips it. Throws `CliError`
173
+ * (exit 1) when any page fails.
174
+ * @param {{ cwd: string, target: string | undefined, log: typeof console.log, error: typeof console.error }} io
175
+ * @returns {{ command: "validate" }}
176
+ */
177
+ function runValidate({ cwd, target, log, error }) {
178
+ const paths = createPaths(cwd);
179
+ if (!fs.existsSync(paths.routesRoot)) {
180
+ error("No site/pages directory found. Run `darkmown init` to scaffold a project first.");
181
+ throw new CliError("No site/pages directory found.", { silent: true });
182
+ }
183
+ const files = slurpSiteSources(cwd);
184
+ const routes = discoverRoutes(paths.routesRoot, { includeDrafts: true });
185
+ let collections;
186
+ try {
187
+ collections = buildCollections(routes, paths);
188
+ } catch (err) {
189
+ // A `_schema.wd` violation is a project-level failure, not one page's: the
190
+ // message already carries the entry file, line, and corrective hint.
191
+ error(`✗ ${err instanceof Error ? err.message : String(err)}`);
192
+ throw new CliError("Collection validation failed.", { silent: true });
193
+ }
194
+
195
+ const entries = validateEntries({ cwd, target, routes, files, error });
196
+ let failures = 0;
197
+ let warnings = 0;
198
+ for (const entry of entries) {
199
+ // The pager vars mirror the build's discovery compile (page 1 of 1, no
200
+ // prev/next), so a paginated listing's `:if page.prev` / `{ page.next }`
201
+ // resolve exactly the way its first compile resolves them.
202
+ const result = validateSource(files, entry, {
203
+ collections,
204
+ vars: { page: { current: 1, total: 1, prev: "", next: "" } }
205
+ });
206
+ const [headline, ...rest] = result.text.split("\n");
207
+ if (result.ok) {
208
+ warnings += rest.length;
209
+ log(`✓ ${entry} — ${headline}`);
210
+ for (const line of rest) log(` ${line}`);
211
+ } else {
212
+ failures++;
213
+ error(`✗ ${entry} — ${headline}`);
214
+ for (const line of rest) error(` ${line}`);
215
+ }
216
+ }
217
+ if (failures > 0) {
218
+ error(`✗ ${failures} of ${entries.length} ${plural(entries.length, "page")} failed validation`);
219
+ throw new CliError("Validation failed.", { silent: true });
220
+ }
221
+ log(
222
+ `✓ ${entries.length} ${plural(entries.length, "page")} validated` +
223
+ (warnings ? `, ${warnings} ${plural(warnings, "warning")}` : "")
224
+ );
225
+ return { command: "validate" };
226
+ }
227
+
228
+ /**
229
+ * Which pages `validate` checks, as project-relative POSIX keys into the
230
+ * in-memory source map: every route for no argument, the routes under a
231
+ * directory argument, or the one file for a file argument (which may be a shelf
232
+ * include — anything under `site/` the compiler can read).
233
+ * @param {{ cwd: string, target: string | undefined, routes: import("./router.js").Route[], files: Record<string, string>, error: typeof console.error }} io
234
+ * @returns {string[]}
235
+ */
236
+ function validateEntries({ cwd, target, routes, files, error }) {
237
+ const keyOf = (/** @type {string} */ abs) => path.relative(cwd, abs).split(path.sep).join("/");
238
+ if (!target) return routes.map((route) => keyOf(route.file));
239
+
240
+ const abs = path.resolve(cwd, target);
241
+ if (!fs.existsSync(abs)) {
242
+ error(`Nothing to validate at "${target}": no such file or directory.`);
243
+ throw new CliError(`No such file or directory: ${target}`, { silent: true });
244
+ }
245
+ if (fs.statSync(abs).isDirectory()) {
246
+ const prefix = `${abs}${path.sep}`;
247
+ const entries = routes
248
+ .filter((route) => route.file.startsWith(prefix))
249
+ .map((route) => keyOf(route.file));
250
+ if (entries.length === 0) {
251
+ error(`No pages found under "${target}".`);
252
+ throw new CliError(`No pages found under ${target}`, { silent: true });
253
+ }
254
+ return entries;
255
+ }
256
+ const ext = path.extname(abs);
257
+ if (ext !== ".md" && ext !== ".wd") {
258
+ error(`Cannot validate "${target}": only .md and .wd sources compile.`);
259
+ throw new CliError(`Not a page source: ${target}`, { silent: true });
260
+ }
261
+ const key = keyOf(abs);
262
+ if (!(key in files)) {
263
+ error(`Cannot validate "${target}": validate checks pages and includes under site/.`);
264
+ throw new CliError(`Outside site/: ${target}`, { silent: true });
265
+ }
266
+ return [key];
267
+ }
268
+
269
+ /**
270
+ * Read every compiler-readable text source under `site/` into the
271
+ * project-relative key → contents map `compileFromMemory` compiles against.
272
+ * @param {string} cwd Project working directory.
273
+ * @returns {Record<string, string>}
274
+ */
275
+ function slurpSiteSources(cwd) {
276
+ /** @type {Record<string, string>} */
277
+ const files = {};
278
+ /** @param {string} dir */
279
+ const walk = (dir) => {
280
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
281
+ const abs = path.join(dir, entry.name);
282
+ if (entry.isDirectory()) {
283
+ walk(abs);
284
+ continue;
285
+ }
286
+ if (!VALIDATE_SOURCE_EXTENSIONS.has(path.extname(entry.name))) continue;
287
+ files[path.relative(cwd, abs).split(path.sep).join("/")] = fs.readFileSync(abs, "utf8");
288
+ }
289
+ };
290
+ const siteRoot = path.join(cwd, "site");
291
+ if (fs.existsSync(siteRoot)) walk(siteRoot);
292
+ return files;
293
+ }
294
+
295
+ /**
296
+ * @param {number} n
297
+ * @param {string} word
298
+ * @returns {string}
299
+ */
300
+ function plural(n, word) {
301
+ return n === 1 ? word : `${word}s`;
302
+ }
303
+
145
304
  /**
146
305
  * Start the live-compiler dev server. Builds once (recording, not crashing on, a
147
306
  * broken initial build), watches `site/` and `src/`, and serves dist with the
@@ -293,6 +452,12 @@ function startDevServer({ cwd, log, warn, error }) {
293
452
  /**
294
453
  * Start the static preview server for an existing `dist/`. Throws `CliError`
295
454
  * when there is nothing built to serve.
455
+ *
456
+ * Preview mirrors production more closely than dev in two ways: project
457
+ * `api/*.js` functions run on `/api/*` (same runner and `(request) => Response`
458
+ * contract as dev — a project without `api/` falls straight through to static),
459
+ * and compressible responses are brotli/gzip-negotiated off Accept-Encoding so
460
+ * local weight checks see transfer sizes, not raw bytes.
296
461
  * @param {{ cwd: string, log: typeof console.log, error: typeof console.error }} io
297
462
  * @returns {Promise<{ command: "serve", server: import("node:http").Server, close: () => Promise<void> }>}
298
463
  */
@@ -304,7 +469,21 @@ function startPreviewServer({ cwd, log, error }) {
304
469
  error("No dist directory found. Run `darkmown build` first.");
305
470
  throw new CliError("No dist directory found. Run `darkmown build` first.", { silent: true });
306
471
  }
307
- const server = http.createServer((req, res) => serve(distRoot, req.url || "/", res));
472
+ const apiDir = path.join(cwd, "api");
473
+ const server = http.createServer((req, res) => {
474
+ const acceptEncoding = req.headers["accept-encoding"];
475
+ // Serverless parity, exactly as in dev: run a project api/*.js function when
476
+ // the path matches; otherwise serve static (compressed when negotiated). A
477
+ // throw on either path (e.g. a malformed dynamic segment) becomes a clean 500.
478
+ handleApiRequest({ apiDir, req, res, acceptEncoding })
479
+ .then((handled) => {
480
+ if (!handled) serve(distRoot, req.url || "/", res, { acceptEncoding });
481
+ })
482
+ .catch((err) => {
483
+ res.writeHead(500, { "content-type": "text/plain" });
484
+ res.end((err instanceof Error && err.stack) || String(err));
485
+ });
486
+ });
308
487
  return new Promise((resolve) => {
309
488
  server.listen(port, host, () => {
310
489
  log(`Darkmown preview of dist at http://${host}:${port}`);
@@ -398,6 +577,7 @@ Usage:
398
577
  darkmown dev Start the live compiler dev server
399
578
  darkmown build [--target cloudflare] Compile site/pages into dist
400
579
  [--drafts] Include draft: true pages (staging)
580
+ darkmown validate [path] Compile-check pages without building (exit 1 on errors)
401
581
  darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
402
582
  darkmown serve Preview the built dist locally
403
583
  darkmown catalog [--llms|--llms-full] Print the .wd directive catalog (JSON, --llms cheatsheet, or --llms-full reference)
@@ -88,6 +88,13 @@ export function compileBody(lines, ctx) {
88
88
  // reports the offending line and not the top of the file.
89
89
  let proseStart = 0;
90
90
  let fence = null;
91
+ // Raw <style>/<script> block tracking (pages with `html: true`). Inside one,
92
+ // lines are CSS/JS, not directive territory, so the "looks like a directive"
93
+ // hint is suppressed — otherwise every `@font-face {` / `@keyframes x {` in
94
+ // an author's raw <style> warns once per page. Line-based like the fence
95
+ // tracking above; a `</style>` inside a CSS string is out of scope.
96
+ /** @type {"style" | "script" | null} */
97
+ let rawTag = null;
91
98
 
92
99
  // Append one chunk, splicing it into the previous one when the seam between
93
100
  // them is a table split in half. Markdown only sees a table when a header row
@@ -127,6 +134,20 @@ export function compileBody(lines, ctx) {
127
134
  continue;
128
135
  }
129
136
 
137
+ // Update the raw-block state BEFORE the line is classified, so both the
138
+ // opener's contents and the closing tag line itself count as "inside".
139
+ // A same-line `<script src="…"></script>` opens and closes at once and
140
+ // never suppresses anything.
141
+ const inRawBlock = rawTag !== null;
142
+ if (rawTag) {
143
+ if (RAW_BLOCK_CLOSE.test(line)) rawTag = null;
144
+ } else {
145
+ const rawOpen = line.match(RAW_BLOCK_OPEN);
146
+ if (rawOpen && !RAW_BLOCK_CLOSE.test(line)) {
147
+ rawTag = /** @type {"style" | "script"} */ (rawOpen[1].toLowerCase());
148
+ }
149
+ }
150
+
130
151
  // A directive name ALONE on a line is a mistake with one obvious fix: it is
131
152
  // missing its arguments. Every dispatcher below requires a space after the
132
153
  // keyword, so such a line used to fall through to prose — and because
@@ -331,7 +352,7 @@ export function compileBody(lines, ctx) {
331
352
  file: ctx.file
332
353
  });
333
354
  }
334
- warnUnknownDirective(line, ctx);
355
+ if (!inRawBlock) warnUnknownDirective(line, ctx);
335
356
  prose.push(line);
336
357
  }
337
358
 
@@ -351,6 +372,14 @@ export function compileBody(lines, ctx) {
351
372
  // from the demo handler and are gone from here for exactly that reason.)
352
373
  const KNOWN_DIRECTIVE =
353
374
  /^(?:(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|every|theme|video|audio|embed|form|endform|input|textarea|select|checkbox|radio|bind|slider|submit|button|carousel|endcarousel|if|endif|else))(?:\s|$)|:try\s)/;
375
+
376
+ // Raw HTML blocks whose contents are CSS/JS, never directive territory. The
377
+ // lookalike hint is suppressed between `<style …>`/`<script …>` and their
378
+ // close tag (line-based, matching the rest of this dispatcher). The close is
379
+ // deliberately tag-agnostic: `<style>` and `<script>` never nest, so either
380
+ // closer ends the open block.
381
+ const RAW_BLOCK_OPEN = /<(style|script)(?=[\s>])/i;
382
+ const RAW_BLOCK_CLOSE = /<\/(?:style|script)\s*>/i;
354
383
  /**
355
384
  * @param {string} line
356
385
  * @param {Ctx} ctx