@zvndev/darkmown 2.7.1-next.14.g3f67070 → 2.9.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, generated at build time.** With sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), every raster photo in `site/pages` over 700px wide is resized to 640w and 1280w siblings in `dist/` (only widths below the intrinsic size, never upscaled, EXIF orientation applied), and the Markdown image gains a `srcset` with the original kept as `src`. Export `name@<width>.ext` yourself and your file wins that width verbatim, measured from its own header; generation fills only the widths you left uncovered, and other sizes are a hand export away (the compiler probes 64 through 3840). Skipped silently: small images, `@width` files, hidden or symlinked paths, svg/gif/avif, and shelf images (the generator covers page-colocated images only). Without sharp the build behaves as before plus one `npm i -D sharp` hint per build; a corrupt image or a sharp failure on one file is a hint naming the file, never a build failure. Resizes are cached in `node_modules/.cache/darkmown-images/` by content hash + width + format, so second builds and dev rebuilds do no resizing for unchanged images, and the build summary reports `image variants (N generated, M cached)` when variants materialized. `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that); bound (`![a]({ photo })`), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. 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,
@@ -20,6 +21,7 @@ import {
20
21
  } from "./feeds.js";
21
22
  import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
22
23
  import { HIGHLIGHT_CSS } from "./highlight.js";
24
+ import { generateImageVariants } from "./image-variants.js";
23
25
  import { discoverRoutes, outputPathForRoute } from "./router.js";
24
26
  import { compileSkin, scopeIdFor } from "./skin.js";
25
27
 
@@ -104,6 +106,9 @@ export function buildSite(cwd = process.cwd(), options = {}) {
104
106
  * @property {RouteManifestEntry[]} routes
105
107
  * @property {string} distRoot
106
108
  * @property {{ sitemap: number | null, rss: number | null }} feeds
109
+ * @property {{ generated: number, cached: number, unavailable: boolean }} images
110
+ * Responsive-variant pre-pass counts (2.9.0): freshly resized, cache-served,
111
+ * and whether sharp could not be loaded at all.
107
112
  * @property {{ changed: string[], rebuilt: string[], total: number }} [incremental]
108
113
  */
109
114
 
@@ -118,6 +123,12 @@ function buildFull(cwd, paths, options) {
118
123
  fs.rmSync(paths.distRoot, { recursive: true, force: true });
119
124
  fs.mkdirSync(paths.distRoot, { recursive: true });
120
125
  emitShelfAssets(paths);
126
+ // Responsive image variants (2.9.0): resize the large raster photos in
127
+ // site/pages into dist and collect the variant map BEFORE any route compiles,
128
+ // so the srcset wiring can reference what was generated. With the optional
129
+ // sharp dependency absent (or everything cache-served) this is cheap and
130
+ // never fails the build.
131
+ const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
121
132
  const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
122
133
  // Build the collection index from the (post-draft-filter) routes — every
123
134
  // site/pages/<name>/ subdirectory's entries become queryable rows a bare-name
@@ -151,7 +162,17 @@ function buildFull(cwd, paths, options) {
151
162
  const mapRoutes = {};
152
163
 
153
164
  for (const route of routes) {
154
- const built = buildRoute(route, cwd, paths, collections, feedLink, warned, identity, titles);
165
+ const built = buildRoute(
166
+ route,
167
+ cwd,
168
+ paths,
169
+ collections,
170
+ feedLink,
171
+ warned,
172
+ identity,
173
+ titles,
174
+ images.variants
175
+ );
155
176
  manifest.push(...built.mapEntry.pages);
156
177
  if (built.notFoundHtml !== undefined) notFoundHtml = built.notFoundHtml;
157
178
  for (const entry of built.mapEntry.pages) {
@@ -191,7 +212,12 @@ function buildFull(cwd, paths, options) {
191
212
 
192
213
  if (options.depMap) writeDepMap(paths, feedLink, siteInputs(identity, titles), mapRoutes);
193
214
 
194
- return { routes: manifest, distRoot: paths.distRoot, feeds };
215
+ return {
216
+ routes: manifest,
217
+ distRoot: paths.distRoot,
218
+ feeds,
219
+ images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable }
220
+ };
195
221
  }
196
222
 
197
223
  /**
@@ -283,13 +309,30 @@ function buildIncremental(cwd, paths, options, changed) {
283
309
  }
284
310
  }
285
311
 
312
+ // Rebuilt routes must compile byte-identically to a full rebuild, so the
313
+ // generated-variant map the srcset wiring consults is recomputed here too.
314
+ // An image CHANGE never reaches this point (no dependency graph tracks
315
+ // images, so it already forced a full rebuild above), which makes this scan
316
+ // all cache hits: it refreshes dist copies and the map, and spawns nothing.
317
+ const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
318
+
286
319
  const byRoute = new Map(routes.map((route) => [route.route, route]));
287
320
  /** @type {Set<string>} */
288
321
  const warned = new Set();
289
322
  const rebuilt = [...affected].sort();
290
323
  for (const routePath of rebuilt) {
291
324
  const route = /** @type {Route} */ (byRoute.get(routePath));
292
- const built = buildRoute(route, cwd, paths, collections, feedLink, warned, identity, titles);
325
+ const built = buildRoute(
326
+ route,
327
+ cwd,
328
+ paths,
329
+ collections,
330
+ feedLink,
331
+ warned,
332
+ identity,
333
+ titles,
334
+ images.variants
335
+ );
293
336
  // A shrunk pagination leaves stale `/page/N/` outputs behind — remove any
294
337
  // previously-emitted page this rebuild no longer produces.
295
338
  const kept = new Set(built.mapEntry.pages.map((entry) => entry.route));
@@ -325,6 +368,7 @@ function buildIncremental(cwd, paths, options, changed) {
325
368
  routes: manifest,
326
369
  distRoot: paths.distRoot,
327
370
  feeds,
371
+ images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable },
328
372
  incremental: { changed, rebuilt, total: routes.length }
329
373
  };
330
374
  }
@@ -343,10 +387,30 @@ function buildIncremental(cwd, paths, options, changed) {
343
387
  * @param {SiteIdentity} identity Resolved site identity (its `siteUrl` is what
344
388
  * makes a canonical URL possible).
345
389
  * @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
390
+ * @param {Map<string, number[]>} imageVariants Generated responsive variants
391
+ * (original's absolute source path → widths), threaded into each compile.
346
392
  * @returns {{ mapEntry: DepMapEntry, notFoundHtml: string | undefined }}
347
393
  */
348
- function buildRoute(route, cwd, paths, collections, feedLink, warned, identity, titles) {
349
- const compiled = compileRoutePages(route, paths, collections, feedLink, identity, titles);
394
+ function buildRoute(
395
+ route,
396
+ cwd,
397
+ paths,
398
+ collections,
399
+ feedLink,
400
+ warned,
401
+ identity,
402
+ titles,
403
+ imageVariants
404
+ ) {
405
+ const compiled = compileRoutePages(
406
+ route,
407
+ paths,
408
+ collections,
409
+ feedLink,
410
+ identity,
411
+ titles,
412
+ imageVariants
413
+ );
350
414
  /** @type {RouteManifestEntry[]} */
351
415
  const entries = [];
352
416
  /** @type {string | undefined} */
@@ -450,7 +514,9 @@ function breadcrumbTrail(routePath, titles, siteUrl) {
450
514
  */
451
515
  function siteCorpus(routes, identity) {
452
516
  const pages = routes
453
- .filter((route) => route.route !== "/404/")
517
+ // `/404/` is not content, and a `noindex: true` page opted out of being
518
+ // enumerated anywhere — feeds, sitemap, and this corpus alike.
519
+ .filter((route) => route.route !== "/404/" && !isNoindex(route.meta))
454
520
  .map((route) => {
455
521
  const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
456
522
  return {
@@ -691,9 +757,11 @@ function copyChangedAsset(file, cwd, paths) {
691
757
  * @param {{ href: string, title: string } | undefined} feedLink
692
758
  * @param {SiteIdentity} identity
693
759
  * @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
760
+ * @param {Map<string, number[]>} imageVariants Generated responsive variants
761
+ * (original's absolute source path → widths) for the srcset wiring.
694
762
  * @returns {{ pages: EmittedPage[], deps: Set<string>, collections: Set<string> }}
695
763
  */
696
- function compileRoutePages(route, paths, collections, feedLink, identity, titles) {
764
+ function compileRoutePages(route, paths, collections, feedLink, identity, titles, imageVariants) {
697
765
  /** @type {Set<string>} */
698
766
  const deps = new Set();
699
767
  /** @type {Set<string>} */
@@ -718,7 +786,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
718
786
  feed: feedLink,
719
787
  site: siteFor(route.route),
720
788
  collections,
721
- vars: { page: pagerVars(route.route, 1, 1) }
789
+ vars: { page: pagerVars(route.route, 1, 1) },
790
+ imageVariants
722
791
  });
723
792
  track(first);
724
793
  if (!first.pagination || first.pagination.total <= 1) {
@@ -743,7 +812,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
743
812
  feed: feedLink,
744
813
  site: siteFor(pageRoute(route.route, n)),
745
814
  collections,
746
- vars: { page: pagerVars(route.route, n, total) }
815
+ vars: { page: pagerVars(route.route, n, total) },
816
+ imageVariants
747
817
  });
748
818
  track(page);
749
819
  pages.push({
@@ -898,8 +968,10 @@ function emitFeeds(routes, identity, paths, quietHints = false) {
898
968
  }
899
969
 
900
970
  // 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/");
971
+ // not a post — and a `noindex: true` route has opted out of both, exactly as
972
+ // it opted out of the index with its robots meta. Filter once; both feeds
973
+ // work off the result.
974
+ const feedable = routes.filter((route) => route.route !== "/404/" && !isNoindex(route.meta));
903
975
 
904
976
  // Sitemap: one <url> per emitted route (404 excluded above). A frontmatter
905
977
  // `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).",