@zvndev/darkmown 2.10.0 → 2.11.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.
Files changed (45) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +37 -3
  4. package/SECURITY.md +1 -0
  5. package/docs/cli.md +20 -1
  6. package/docs/errors.md +255 -0
  7. package/docs/spec-alignment.md +14 -1
  8. package/package.json +3 -2
  9. package/site/pages/benchmarks/index.wd +1 -1
  10. package/site/pages/docs/index.wd +6 -1
  11. package/site/pages/index.wd +1 -0
  12. package/site/pages/playground/index.js +59 -54
  13. package/src/api-runner.js +80 -8
  14. package/src/builder.js +122 -13
  15. package/src/catalog.js +6 -0
  16. package/src/cli.js +48 -5
  17. package/src/compiler/page.js +16 -4
  18. package/src/compiler/state.js +75 -0
  19. package/src/compiler.js +1 -1
  20. package/src/errors.js +50 -3
  21. package/src/font-subset.js +25 -7
  22. package/src/image-variant-resize.js +4 -1
  23. package/src/image-variants.js +9 -4
  24. package/src/mcp-tools.js +354 -0
  25. package/src/mcp.js +287 -0
  26. package/src/router.js +16 -0
  27. package/src/skin.js +111 -45
  28. package/src/statics.js +1 -0
  29. package/src/templates/dashboard/README.md +3 -1
  30. package/src/templates/landing/site/pages/index.skin +7 -0
  31. package/src/templates/landing/site/pages/index.wd +10 -3
  32. package/src/templates/store/README.md +5 -2
  33. package/src/templates/store/site/_/card.wd +19 -0
  34. package/src/templates/store/site/_/products.json +6 -6
  35. package/src/templates/store/site/pages/index.skin +17 -2
  36. package/src/templates/store/site/pages/index.wd +3 -12
  37. package/types/api-runner.d.ts +20 -4
  38. package/types/builder.d.ts +5 -0
  39. package/types/compiler/page.d.ts +12 -3
  40. package/types/compiler.d.ts +4 -1
  41. package/types/errors.d.ts +6 -4
  42. package/types/font-subset.d.ts +7 -3
  43. package/types/image-variants.d.ts +7 -3
  44. package/types/router.d.ts +5 -0
  45. package/types/skin.d.ts +9 -4
@@ -0,0 +1,19 @@
1
+ ---
2
+ defaults: [note="Ships in 2 days"]
3
+ ---
4
+
5
+ ::: card .product
6
+ ::: tile
7
+ { p.initial }
8
+ :::
9
+ ::: pname
10
+ { p.name }
11
+ :::
12
+ ::: price
13
+ { p.price | money }
14
+ :::
15
+ ::: note
16
+ { note }
17
+ :::
18
+ { children }
19
+ :::
@@ -1,8 +1,8 @@
1
1
  [
2
- { "name": "Ceramic Mug", "emoji": "☕️", "price": 18 },
3
- { "name": "Linen Tote", "emoji": "👜", "price": 32 },
4
- { "name": "Pocket Notebook", "emoji": "📓", "price": 12 },
5
- { "name": "Desk Lamp", "emoji": "💡", "price": 64 },
6
- { "name": "Plant Pot", "emoji": "🪴", "price": 24 },
7
- { "name": "Wall Clock", "emoji": "🕰️", "price": 48 }
2
+ { "name": "Ceramic Mug", "initial": "C", "price": 18 },
3
+ { "name": "Linen Tote", "initial": "L", "price": 32 },
4
+ { "name": "Pocket Notebook", "initial": "P", "price": 12 },
5
+ { "name": "Desk Lamp", "initial": "D", "price": 64 },
6
+ { "name": "Plant Pot", "initial": "P", "price": 24 },
7
+ { "name": "Wall Clock", "initial": "W", "price": 48 }
8
8
  ]
@@ -93,13 +93,28 @@ input:focus-visible, select:focus-visible, textarea:focus-visible
93
93
  border-color $accent
94
94
  box-shadow 0 12px 26px -16px rgba(26,31,43,.4)
95
95
 
96
- .emoji
97
- font-size 2.25rem
96
+ .tile
97
+ width 3rem
98
+ height 3rem
99
+ margin 0 auto
100
+ display grid
101
+ place-items center
102
+ font-size 1.3rem
103
+ font-weight 700
104
+ color $accent-deep
105
+ bg $paper
106
+ border 1px solid $line
107
+ radius 10px
98
108
 
99
109
  .pname
100
110
  font-weight 600
101
111
  margin .35rem 0 .15rem
102
112
 
113
+ .note
114
+ color $muted
115
+ font-size .82rem
116
+ margin-bottom .6rem
117
+
103
118
  .price
104
119
  color $accent
105
120
  font-weight 600
@@ -23,18 +23,9 @@ transitions: true
23
23
 
24
24
  ::: catalog
25
25
  @loop /products.json into p where p.name contains q
26
- ::: card .product
27
- ::: emoji
28
- { p.emoji }
29
- :::
30
- ::: pname
31
- { p.name }
32
- :::
33
- ::: price
34
- { p.price | money }
35
- :::
26
+ @include /card.wd
36
27
  :button "Add to cart" -> cart += p
37
- :::
28
+ @endinclude
38
29
  @empty
39
30
  ::: card .empty
40
31
  Nothing matches "**{ q }**".
@@ -46,7 +37,7 @@ Nothing matches "**{ q }**".
46
37
 
47
38
  @loop cart into item
48
39
  ::: card .line
49
- { item.emoji } **{ item.name }** — { item.price | money }
40
+ **{ item.name }** — { item.price | money }
50
41
  :button "Remove" -> cart remove item
51
42
  :::
52
43
  @empty
@@ -38,18 +38,25 @@ export function discoverApiRoutes(apiDir: string): ApiRoute[];
38
38
  /**
39
39
  * Match a request pathname against discovered routes. The pathname is expected
40
40
  * to start with `/api`; anything else returns `null` (caller serves static).
41
+ * A dynamic segment whose percent-encoding is malformed (`%E0%A4%A`) makes
42
+ * `decodeURIComponent` throw — that is reported as `{ malformed: true }` so the
43
+ * caller answers a clean 400 instead of crashing the request handler.
41
44
  * @param {ApiRoute[]} routes
42
45
  * @param {string} pathname Request pathname (no query string).
43
- * @returns {{ file: string, params: Record<string, string> } | null}
46
+ * @returns {{ file: string, params: Record<string, string> } | { malformed: true } | null}
44
47
  */
45
48
  export function matchApiRoute(routes: ApiRoute[], pathname: string): {
46
49
  file: string;
47
50
  params: Record<string, string>;
51
+ } | {
52
+ malformed: true;
48
53
  } | null;
49
54
  /**
50
55
  * Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
51
- * methods that carry one) is buffered fully — dev endpoints are small and this
52
- * avoids the `duplex: "half"` streaming dance.
56
+ * methods that carry one) is buffered up to {@link MAX_BODY_BYTES} — dev and
57
+ * preview endpoints are small and this avoids the `duplex: "half"` streaming
58
+ * dance. A larger body rejects with {@link BodyTooLargeError}, which
59
+ * `handleApiRequest` answers as a 413.
53
60
  * @param {import("node:http").IncomingMessage} req
54
61
  * @returns {Promise<Request>}
55
62
  */
@@ -75,19 +82,28 @@ export function writeWebResponse(response: Response, res: import("node:http").Se
75
82
  * the handler succeeded or threw), `false` when nothing matched so the caller
76
83
  * should fall through to static serving.
77
84
  *
85
+ * Error disclosure: a handler throw is answered as JSON either way, but the
86
+ * `error` field carries the real message only when `discloseErrors` is set
87
+ * (the dev server's default — a documented dev convenience). The preview
88
+ * server passes `false` and the client gets the generic "Internal error",
89
+ * matching the shape the generated Cloudflare worker ships.
90
+ *
78
91
  * @param {object} io
79
92
  * @param {string} io.apiDir Absolute path to the project's `api/` directory.
80
93
  * @param {import("node:http").IncomingMessage} io.req
81
94
  * @param {import("node:http").ServerResponse} io.res
82
95
  * @param {string | string[] | undefined} [io.acceptEncoding] The request's
83
96
  * Accept-Encoding header, when the caller negotiates compression (preview).
97
+ * @param {boolean} [io.discloseErrors] Echo the thrown message to the client
98
+ * (default true, the dev behavior). Pass false on any production-shaped path.
84
99
  * @returns {Promise<boolean>}
85
100
  */
86
- export function handleApiRequest({ apiDir, req, res, acceptEncoding }: {
101
+ export function handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors }: {
87
102
  apiDir: string;
88
103
  req: import("node:http").IncomingMessage;
89
104
  res: import("node:http").ServerResponse;
90
105
  acceptEncoding?: string | string[] | undefined;
106
+ discloseErrors?: boolean | undefined;
91
107
  }): Promise<boolean>;
92
108
  /**
93
109
  * A discovered API route.
@@ -169,6 +169,11 @@ export type SiteIdentity = {
169
169
  * Most-recent-first RSS item cap, from `rss_limit:`.
170
170
  */
171
171
  rssLimit: number;
172
+ /**
173
+ * Publish every page's markdown source at
174
+ * `<route>.md`, from the home page's `md_source: true` (2.11).
175
+ */
176
+ mdSource: boolean;
172
177
  };
173
178
  export type Paths = import("./config.js").Paths;
174
179
  export type Assets = import("./compiler.js").Assets;
@@ -2,10 +2,13 @@
2
2
  * Compile a page source file into a full HTML document plus its assets.
3
3
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
4
4
  * @param {Paths} context Resolved project paths.
5
- * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
5
+ * @param {{ feed?: { href: string, title: string }, mdSource?: { href: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
6
6
  * When a site-wide RSS feed is emitted (the home page set `site_url` and the
7
7
  * site has dated posts), `feed` carries its absolute href + title so every page
8
- * links it. `site` carries the origin + this page's own route (and its resolved
8
+ * links it. When the site publishes markdown sources (the home page set
9
+ * `md_source: true` and this page did not opt out), `mdSource` carries the
10
+ * page's own `<route>.md` href for the alternate link. `site` carries the
11
+ * origin + this page's own route (and its resolved
9
12
  * breadcrumb trail) so the shell can state a canonical URL. `collections` is the
10
13
  * build-time collection index a bare-name `@loop` resolves against; `vars` seeds
11
14
  * the document scope (the pager `page` object on a paginated route). `reader` is
@@ -19,6 +22,9 @@ export function compilePage(file: string, context: Paths, options?: {
19
22
  href: string;
20
23
  title: string;
21
24
  };
25
+ mdSource?: {
26
+ href: string;
27
+ };
22
28
  site?: SiteContext;
23
29
  collections?: Map<string, import("./collections.js").CollectionRow[]>;
24
30
  vars?: Record<string, unknown>;
@@ -103,7 +109,7 @@ export function foldBuildBindings(text: string, now?: Date): string;
103
109
  * @param {Record<string, string> | Map<string, string>} files Project-relative
104
110
  * path → source content.
105
111
  * @param {string} entryPath Project-relative path of the page to compile.
106
- * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, cwd?: string }} [options]
112
+ * @param {{ feed?: { href: string, title: string }, mdSource?: { href: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, cwd?: string }} [options]
107
113
  * Same shell options as {@link compilePage}; `cwd` overrides the virtual
108
114
  * project root the relative keys resolve against (defaults to `/`).
109
115
  * @returns {CompiledPage}
@@ -113,6 +119,9 @@ export function compileFromMemory(files: Record<string, string> | Map<string, st
113
119
  href: string;
114
120
  title: string;
115
121
  };
122
+ mdSource?: {
123
+ href: string;
124
+ };
116
125
  site?: SiteContext;
117
126
  collections?: Map<string, import("./collections.js").CollectionRow[]>;
118
127
  vars?: Record<string, unknown>;
@@ -4,7 +4,7 @@
4
4
  * a different source (see `compileFromMemory`).
5
5
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
6
6
  * @param {import("./config.js").Paths} context Resolved project paths.
7
- * @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
7
+ * @param {{ feed?: { href: string, title: string }, mdSource?: { href: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
8
8
  * @returns {CompiledPage}
9
9
  */
10
10
  export function compilePage(file: string, context: import("./config.js").Paths, options?: {
@@ -12,6 +12,9 @@ export function compilePage(file: string, context: import("./config.js").Paths,
12
12
  href: string;
13
13
  title: string;
14
14
  };
15
+ mdSource?: {
16
+ href: string;
17
+ };
15
18
  site?: import("./compiler/page.js").SiteContext;
16
19
  collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>;
17
20
  vars?: Record<string, unknown>;
package/types/errors.d.ts CHANGED
@@ -33,8 +33,9 @@ export function errorsMarkdown(): string;
33
33
  * @property {string} title Short name for the mistake.
34
34
  * @property {string} cause What the author wrote that triggers it.
35
35
  * @property {string} fix The corrective action, in one line.
36
- * @property {string} [example] A concrete, compilable line, sourced from the
37
- * compiler's own `*_EXAMPLE` constants.
36
+ * @property {string} [example] A concrete, compilable line (or, for block
37
+ * constructs like the `.skin` at-rules, a minimal compilable block), sourced
38
+ * from the compiler's own `*_EXAMPLE` constants.
38
39
  */
39
40
  /** The subsystem blocks, in code order. */
40
41
  /** @type {ErrorArea[]} */
@@ -93,8 +94,9 @@ export type ErrorEntry = {
93
94
  */
94
95
  fix: string;
95
96
  /**
96
- * A concrete, compilable line, sourced from the
97
- * compiler's own `*_EXAMPLE` constants.
97
+ * A concrete, compilable line (or, for block
98
+ * constructs like the `.skin` at-rules, a minimal compilable block), sourced
99
+ * from the compiler's own `*_EXAMPLE` constants.
98
100
  */
99
101
  example?: string | undefined;
100
102
  };
@@ -29,17 +29,21 @@ export function subsetFonts(paths: Paths, options?: {
29
29
  * build is synchronous; the wasm API is not), communicating through temp JSON
30
30
  * files rather than stdio so a large payload never hits a pipe buffer. The
31
31
  * worker always writes a results file — a missing or unparsable one means the
32
- * child itself died, which is reported as `unavailable` (the build then behaves
33
- * as if the toolchain were absent: a hint, no subsets, no failure).
32
+ * child itself died (crashed, or killed by the spawn timeout), which is
33
+ * reported as `unavailable` (the build then behaves as if the toolchain were
34
+ * absent: a hint, no subsets, no failure).
34
35
  * @param {{ charset: number[], jobs: SubsetJob[] }} payload
35
36
  * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
36
37
  * Injectable for tests; the real one is `spawnSync`.
38
+ * @param {number} [timeoutMs] The worker is killed after this long (default
39
+ * two minutes), so a hung toolchain call cannot stall a build forever. The
40
+ * kill lands in the same `unavailable` path as any other dead child.
37
41
  * @returns {SubsetResult}
38
42
  */
39
43
  export function runSubsetJobs(payload: {
40
44
  charset: number[];
41
45
  jobs: SubsetJob[];
42
- }, spawn?: (cmd: string, args: string[], opts: object) => unknown): SubsetResult;
46
+ }, spawn?: (cmd: string, args: string[], opts: object) => unknown, timeoutMs?: number): SubsetResult;
43
47
  /**
44
48
  * The subset charset for a build: the base set plus every code point appearing
45
49
  * in the given texts (the emitted pages' HTML). Numeric character references
@@ -29,14 +29,18 @@ export function generateImageVariants(paths: Paths, options?: {
29
29
  * build is synchronous; sharp is not), communicating through temp JSON files
30
30
  * rather than stdio so a large job list never hits a pipe buffer. The worker
31
31
  * always writes a results file — a missing or unparsable one means the child
32
- * itself died, which is reported as `unavailable` (the build then behaves as
33
- * if sharp were absent: a hint, no variants, no failure).
32
+ * itself died (crashed, or killed by the spawn timeout), which is reported as
33
+ * `unavailable` (the build then behaves as if sharp were absent: a hint, no
34
+ * variants, no failure).
34
35
  * @param {ResizeJob[]} jobs
35
36
  * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
36
37
  * Injectable for tests; the real one is `spawnSync`.
38
+ * @param {number} [timeoutMs] The worker is killed after this long (default
39
+ * two minutes), so a hung sharp call cannot stall a build forever. The kill
40
+ * lands in the same `unavailable` path as any other dead child.
37
41
  * @returns {ResizeResult}
38
42
  */
39
- export function runResizeJobs(jobs: ResizeJob[], spawn?: (cmd: string, args: string[], opts: object) => unknown): ResizeResult;
43
+ export function runResizeJobs(jobs: ResizeJob[], spawn?: (cmd: string, args: string[], opts: object) => unknown, timeoutMs?: number): ResizeResult;
40
44
  /**
41
45
  * @typedef {import("./config.js").Paths} Paths
42
46
  */
package/types/router.d.ts CHANGED
@@ -38,6 +38,11 @@ export function discoverRoutes(routesRoot: string, options?: {
38
38
  export function isDraft(meta: Meta): boolean;
39
39
  /**
40
40
  * Derive a public route path from a source file path.
41
+ *
42
+ * Fail-closed on control/whitespace characters: the route string flows RAW
43
+ * into generated config (`dist/_headers` blocks, llms.txt lines), where a
44
+ * newline in a filename would inject whole config lines. Rejecting here, at
45
+ * the one route-derivation chokepoint, protects every downstream sink at once.
41
46
  * @param {string} routesRoot Absolute path to `site/pages`.
42
47
  * @param {string} file Absolute path to the source file.
43
48
  * @returns {string}
package/types/skin.d.ts CHANGED
@@ -33,16 +33,21 @@ export function scopeIdFor(relPath: string): string;
33
33
  * selectors), they are ALWAYS global (never scope-stamped, same contract as
34
34
  * `tokens`), and their declaration values use the same alias/`$token` pipeline.
35
35
  * @param {string} source
36
- * @param {{ scope?: string, subjects?: Set<string> }} [opts] `scope`: the scope
37
- * id enabling scoped mode. `subjects`: an optional set the compiler fills with
38
- * every scoped subject token (`.card`, `h2`, `#id`) for the unused-selector
39
- * warning, so the caller need not re-parse the source.
36
+ * @param {{ scope?: string, subjects?: Set<string>, file?: string }} [opts]
37
+ * `scope`: the scope id enabling scoped mode. `subjects`: an optional set the
38
+ * compiler fills with every scoped subject token (`.card`, `h2`, `#id`) for
39
+ * the unused-selector warning, so the caller need not re-parse the source.
40
+ * `file`: the skin's path, threaded into author-facing errors so they report
41
+ * `file:line` (the builder always passes it; a bare call reports `on line N`).
40
42
  * @returns {string}
41
43
  */
42
44
  export function compileSkin(source: string, opts?: {
43
45
  scope?: string;
44
46
  subjects?: Set<string>;
47
+ file?: string;
45
48
  }): string;
49
+ export const FONT_FACE_EXAMPLE: "@font-face\n font-family \"Newsreader\"";
50
+ export const KEYFRAMES_EXAMPLE: "@keyframes fade-in\n to\n opacity 1";
46
51
  /**
47
52
  * A nesting frame in the indentation stack.
48
53
  */