@zvndev/darkmown 2.1.0 → 2.2.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.
@@ -8,7 +8,7 @@
8
8
  // while keeping the message text and `Use:` hints intact.
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- import { at } from "./context.js";
11
+ import { at, lineOf, wdError } from "./context.js";
12
12
  import { astOf, evalAst } from "./expr-ast.js";
13
13
  import { escapeHtml, parseStateValue, safeScriptJson } from "./interpolation.js";
14
14
  import { compileComputedExpr } from "./predicates.js";
@@ -17,6 +17,13 @@ import { compileComputedExpr } from "./predicates.js";
17
17
  * @typedef {import("./context.js").Ctx} Ctx
18
18
  */
19
19
 
20
+ // Concrete, compilable one-liners for the state-family hint tails + the catalog.
21
+ export const STATE_EXAMPLE = ":state count = 0";
22
+ export const STORE_EXAMPLE = ":store cart = []";
23
+ export const COMPUTED_EXAMPLE = ":computed total = items.length * 4";
24
+ export const THEME_EXAMPLE = ":theme";
25
+ const STATE_USE = `Use: :state name = value [persist] — e.g. ${STATE_EXAMPLE}`;
26
+
20
27
  /**
21
28
  * @param {string} line
22
29
  * @param {Ctx} ctx
@@ -25,7 +32,13 @@ import { compileComputedExpr } from "./predicates.js";
25
32
  */
26
33
  export function handleState(line, ctx, index) {
27
34
  const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
28
- if (!match) throw new Error(`Malformed :state in ${at(ctx, index)}: ${line}`);
35
+ if (!match)
36
+ throw wdError(`Malformed :state in ${at(ctx, index)}: ${line}. ${STATE_USE}`, {
37
+ file: ctx.file,
38
+ line: lineOf(ctx, index),
39
+ hint: STATE_USE.slice("Use: ".length),
40
+ example: STATE_EXAMPLE
41
+ });
29
42
  const value = parseStateValue(match[2], at(ctx, index));
30
43
  const key = declareState(match[1], value, ctx);
31
44
  const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
@@ -43,8 +56,13 @@ export function declareState(name, value, ctx) {
43
56
  if (ctx.loopItem)
44
57
  throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
45
58
  if (ctx.comp.stores.has(name))
46
- throw new Error(
47
- `State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
59
+ throw wdError(
60
+ `State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
61
+ {
62
+ file: ctx.file,
63
+ hint: `:store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
64
+ example: STORE_EXAMPLE
65
+ }
48
66
  );
49
67
  const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
50
68
  if (ctx.comp.state.has(key))
@@ -63,8 +81,14 @@ export function declareState(name, value, ctx) {
63
81
  export function handleStore(line, ctx, index) {
64
82
  const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
65
83
  if (!match)
66
- throw new Error(
67
- `Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral]`
84
+ throw wdError(
85
+ `Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral] — e.g. ${STORE_EXAMPLE}`,
86
+ {
87
+ file: ctx.file,
88
+ line: lineOf(ctx, index),
89
+ hint: `:store name = value [ephemeral] — e.g. ${STORE_EXAMPLE}`,
90
+ example: STORE_EXAMPLE
91
+ }
68
92
  );
69
93
  const value = parseStateValue(match[2], at(ctx, index));
70
94
  const name = declareStore(match[1], value, ctx);
@@ -83,10 +107,22 @@ export function handleStore(line, ctx, index) {
83
107
  */
84
108
  export function declareStore(name, value, ctx) {
85
109
  if (ctx.comp.stores.has(name))
86
- throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
110
+ throw wdError(
111
+ `Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value — e.g. ${STORE_EXAMPLE}`,
112
+ {
113
+ file: ctx.file,
114
+ hint: `:store name = value — e.g. ${STORE_EXAMPLE}`,
115
+ example: STORE_EXAMPLE
116
+ }
117
+ );
87
118
  if (ctx.comp.state.has(name))
88
- throw new Error(
89
- `Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
119
+ throw wdError(
120
+ `Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
121
+ {
122
+ file: ctx.file,
123
+ hint: `:store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
124
+ example: STORE_EXAMPLE
125
+ }
90
126
  );
91
127
  ctx.comp.stores.add(name);
92
128
  ctx.comp.state.set(name, value);
@@ -115,9 +151,12 @@ export function declareErrorState(key, ctx) {
115
151
  export function handleComputed(line, ctx, index) {
116
152
  const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
117
153
  if (!match)
118
- throw new Error(
119
- `Malformed :computed in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
120
- );
154
+ throw wdError(`Malformed :computed in ${at(ctx, index)}: ${line}. Use: ${COMPUTED_EXAMPLE}`, {
155
+ file: ctx.file,
156
+ line: lineOf(ctx, index),
157
+ hint: COMPUTED_EXAMPLE,
158
+ example: COMPUTED_EXAMPLE
159
+ });
121
160
  const expr = compileComputedExpr(match[2].trim(), ctx);
122
161
  // Parse the validated expression to the compact AST the runtime walks. A parse
123
162
  // failure means the RHS is syntactically malformed (e.g. `|| <`) — a compile
@@ -127,8 +166,14 @@ export function handleComputed(line, ctx, index) {
127
166
  try {
128
167
  ast = astOf(expr);
129
168
  } catch {
130
- throw new Error(
131
- `Malformed :computed expression in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
169
+ throw wdError(
170
+ `Malformed :computed expression in ${at(ctx, index)}: ${line}. Use: ${COMPUTED_EXAMPLE}`,
171
+ {
172
+ file: ctx.file,
173
+ line: lineOf(ctx, index),
174
+ hint: COMPUTED_EXAMPLE,
175
+ example: COMPUTED_EXAMPLE
176
+ }
132
177
  );
133
178
  }
134
179
  // Build-time mirror of the runtime: walk the AST (no eval) so `:computed total =
@@ -155,8 +200,14 @@ export function handleComputed(line, ctx, index) {
155
200
  export function handleTheme(line, ctx, index) {
156
201
  const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
157
202
  if (!match) {
158
- throw new Error(
159
- `Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto")`
203
+ throw wdError(
204
+ `Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto") — e.g. ${THEME_EXAMPLE}`,
205
+ {
206
+ file: ctx.file,
207
+ line: lineOf(ctx, index),
208
+ hint: `:theme (or :theme name = "auto") — e.g. ${THEME_EXAMPLE}`,
209
+ example: THEME_EXAMPLE
210
+ }
160
211
  );
161
212
  }
162
213
  const name = match[1] || "theme";
@@ -11,7 +11,7 @@
11
11
  // while keeping the message text and `Use:` hints intact.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- import { at, createScope, LOOP_META, nestedCtx } from "./context.js";
14
+ import { at, createScope, LOOP_META, lineOf, nestedCtx, wdError } from "./context.js";
15
15
  import { astOf, serializeExpr } from "./expr-ast.js";
16
16
  import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
17
17
  import {
@@ -28,6 +28,13 @@ import { compileWhen, evalPredicate } from "./predicates.js";
28
28
  * @typedef {import("./context.js").Ctx} Ctx
29
29
  */
30
30
 
31
+ // Concrete, compilable structure examples for the hint tails + the catalog.
32
+ export const INCLUDE_EXAMPLE = "@include /header.wd";
33
+ export const CONTAINER_EXAMPLE = "::: card .featured";
34
+ export const IF_EXAMPLE = ":if count > 0";
35
+ export const CAROUSEL_EXAMPLE = ":carousel autoplay=3000";
36
+ const INCLUDE_USE = `Use: @include /partial.wd [with key="value"] — e.g. ${INCLUDE_EXAMPLE}`;
37
+
31
38
  // Container names that emit their own semantic landmark element instead of a
32
39
  // `<div>`, so scaffolds get real `<nav>`/`<main>` landmarks (accessibility: the
33
40
  // skip link targets a genuine main-content region and navigation chrome sits in
@@ -44,7 +51,13 @@ const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
44
51
  */
45
52
  export function handleInclude(line, ctx, index) {
46
53
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
47
- if (!match) throw new Error(`Malformed @include in ${at(ctx, index)}: ${line}`);
54
+ if (!match)
55
+ throw wdError(`Malformed @include in ${at(ctx, index)}: ${line}. ${INCLUDE_USE}`, {
56
+ file: ctx.file,
57
+ line: lineOf(ctx, index),
58
+ hint: INCLUDE_USE.slice("Use: ".length),
59
+ example: INCLUDE_EXAMPLE
60
+ });
48
61
  const target = resolveInclude(
49
62
  match[1],
50
63
  ctx.file,
@@ -215,10 +228,15 @@ export function handleCarousel(line, bodyLines, ctx, index) {
215
228
  let autoplayAttr = "";
216
229
  if (rest) {
217
230
  const auto = rest.match(/^autoplay=(\d+)$/);
218
- if (!auto)
219
- throw new Error(
220
- `Malformed :carousel in ${at(ctx, index)}: ${line}. Use: :carousel [autoplay=3000] … :endcarousel`
221
- );
231
+ if (!auto) {
232
+ const hint = `:carousel [autoplay=3000] … :endcarousel — e.g. ${CAROUSEL_EXAMPLE}`;
233
+ throw wdError(`Malformed :carousel in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
234
+ file: ctx.file,
235
+ line: lineOf(ctx, index),
236
+ hint,
237
+ example: CAROUSEL_EXAMPLE
238
+ });
239
+ }
222
240
  autoplayAttr = ` data-wd-carousel-autoplay="${auto[1]}"`;
223
241
  }
224
242
  ctx.comp.assets.behaviors.add("carousel");
@@ -323,10 +341,15 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
323
341
  // whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
324
342
  // build when static, drives a per-row each-if when it reads the loop item, and
325
343
  // a global if-region (evaluated each render) when it reads state. No raw eval.
326
- if (!condition)
327
- throw new Error(
328
- `Malformed :if in ${at(ctx, index)}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`
329
- );
344
+ if (!condition) {
345
+ const hint = `":if name" or ":if a <op> b [and|or|not …]" — e.g. ${IF_EXAMPLE}`;
346
+ throw wdError(`Malformed :if in ${at(ctx, index)}: ${line}. Use ${hint}`, {
347
+ file: ctx.file,
348
+ line: lineOf(ctx, index),
349
+ hint,
350
+ example: IF_EXAMPLE
351
+ });
352
+ }
330
353
  const compiled = compileWhen(condition, ctx, '":if"');
331
354
  if (compiled.static)
332
355
  return ctx.compileBody(
package/src/grammar.js ADDED
@@ -0,0 +1,131 @@
1
+ // ---------------------------------------------------------------------------
2
+ // GBNF grammar generator for `.wd` DIRECTIVE LINES.
3
+ //
4
+ // `generateGrammar()` emits a llama.cpp-compatible GBNF grammar that constrains
5
+ // a small model's decoding to well-formed Darkmown directive lines (plus the
6
+ // `{ path | pipe }` interpolation token). It is generated from the SAME catalog
7
+ // the cheatsheet uses, so the keyword set, `@loop` clauses, comparison operators,
8
+ // and format-pipe names can never drift from what compiles.
9
+ //
10
+ // Scope (stated deliberately): this grammar constrains the SHAPE of a directive
11
+ // line — the leading keyword must be a real directive, `@loop` must be
12
+ // `@loop <src> into <item> …clauses`, a `where`/`:if` condition must be
13
+ // `operand <op> operand`, a `:button` must be `"…" -> <action-chain>`, and an
14
+ // interpolation must pipe through a known formatter. It does NOT try to grammar-
15
+ // check free-form values (arbitrary JSON seeds, URLs, prose) — those are `text`.
16
+ // That is enough to delete the on-device failure class (JS/HTML muscle-memory
17
+ // openers like `::: card class=` or `cart.filter(...)` become unrepresentable)
18
+ // while staying small and maintainable.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { directiveCatalog } from "./catalog.js";
22
+
23
+ /** Slugify a directive token into a GBNF rule name (`@loop` → `dir-loop`). */
24
+ /** @type {Record<string, string>} */
25
+ const SLUG = {
26
+ "@include": "dir-include",
27
+ "@loop": "dir-loop",
28
+ ":::": "dir-container",
29
+ ":if": "dir-if",
30
+ ":state": "dir-state",
31
+ ":store": "dir-store",
32
+ ":computed": "dir-computed",
33
+ ":fetch": "dir-fetch",
34
+ ":effect": "dir-effect",
35
+ ":every": "dir-every",
36
+ ":theme": "dir-theme",
37
+ ":button": "dir-button",
38
+ ":form": "dir-form",
39
+ ":input": "dir-input",
40
+ ":textarea": "dir-textarea",
41
+ ":select": "dir-select",
42
+ ":checkbox": "dir-checkbox",
43
+ ":radio": "dir-radio",
44
+ ":submit": "dir-submit",
45
+ ":bind": "dir-bind",
46
+ ":slider": "dir-slider",
47
+ ":video": "dir-video",
48
+ ":audio": "dir-audio",
49
+ ":embed": "dir-embed",
50
+ ":carousel": "dir-carousel"
51
+ };
52
+
53
+ // The directives given a structured RHS below; every other directive gets the
54
+ // generic `"<keyword>" restofline` rule (keyword pinned, argument tail free).
55
+ const STRUCTURED = new Set(["@loop", ":if", ":button", ":::"]);
56
+
57
+ /** A GBNF alternation literal list, e.g. `"a" | "b"`. */
58
+ const alt = (/** @type {string[]} */ items) => items.map((s) => `"${s}"`).join(" | ");
59
+
60
+ /**
61
+ * Generate the GBNF grammar for `.wd` directive lines from the directive catalog.
62
+ * @returns {string}
63
+ */
64
+ export function generateGrammar() {
65
+ const cat = directiveCatalog();
66
+ const dirRules = cat.directives.map((d) => SLUG[d.name]).filter(Boolean);
67
+
68
+ /** @type {string[]} */
69
+ const lines = [];
70
+ lines.push("# Darkmown .wd directive-line grammar — generated by src/grammar.js");
71
+ lines.push(
72
+ "# Constrains a directive line (or a { path | pipe } interpolation). Do not edit by hand."
73
+ );
74
+ lines.push("");
75
+ lines.push("root ::= directive | binding");
76
+ lines.push(`directive ::= ${dirRules.join(" | ")}`);
77
+ lines.push("");
78
+
79
+ // Structured directives.
80
+ lines.push('dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses');
81
+ lines.push('dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )');
82
+ lines.push('dir-button ::= ":button" sp dqstring sp "->" sp action-chain');
83
+ lines.push('dir-container ::= ":::" restofline');
84
+ lines.push("");
85
+
86
+ // Generic directives: keyword pinned, argument tail free to end of line.
87
+ for (const d of cat.directives) {
88
+ if (STRUCTURED.has(d.name)) continue;
89
+ lines.push(`${SLUG[d.name]} ::= "${d.name}" restofline`);
90
+ }
91
+ lines.push("");
92
+
93
+ // @loop clause vocabulary (mirrors the fixed clause order in the catalog).
94
+ lines.push("loop-clauses ::= ( sp loop-clause )*");
95
+ lines.push(
96
+ 'loop-clause ::= where-clause | "sort by" sp path ( sp ( "asc" | "desc" ) )? | "reverse" | "offset" sp number | "limit" sp number | "paginate" sp number | "sortable"'
97
+ );
98
+ lines.push('where-clause ::= "where" sp condition ( sp joiner sp condition )*');
99
+ lines.push("condition ::= operand sp op sp operand");
100
+ lines.push(`op ::= ${alt(cat.predicateOps.map((o) => o.name))}`);
101
+ lines.push(`joiner ::= ${alt(cat.predicateJoiners.filter((j) => j !== "not"))}`);
102
+ lines.push("");
103
+
104
+ // Button-action vocabulary. Each operator token below is asserted present
105
+ // against the catalog's action tokens in tests/grammar.test.js.
106
+ lines.push('action-chain ::= action ( wsopt ";" wsopt action )*');
107
+ lines.push("action ::= ident action-op");
108
+ lines.push(
109
+ 'action-op ::= "++" | "--" | sp "+=" sp value | sp "-=" sp value | sp "=" sp value | sp "toggle" ( sp value )? | sp "append" sp value | sp "prepend" sp value | sp "remove" sp value | sp "clear" | sp "merge" sp ident | sp "delete" sp value | sp "reset" | sp "refetch"'
110
+ );
111
+ lines.push("");
112
+
113
+ // Interpolation binding with format pipes.
114
+ lines.push('binding ::= "{" wsopt path pipe-chain wsopt "}"');
115
+ lines.push('pipe-chain ::= ( wsopt "|" wsopt pipe ( ":" value )* )*');
116
+ lines.push(`pipe ::= ${alt(cat.formatPipes.map((p) => p.name))}`);
117
+ lines.push("");
118
+
119
+ // Terminals.
120
+ lines.push("operand ::= path | dqstring | number");
121
+ lines.push("value ::= dqstring | number | path");
122
+ lines.push("ident ::= [a-zA-Z_$] [a-zA-Z0-9_$]*");
123
+ lines.push("path ::= [a-zA-Z0-9_$/.~-]+");
124
+ lines.push("number ::= [0-9]+");
125
+ lines.push('dqstring ::= "\\"" [^"]* "\\""');
126
+ lines.push("restofline ::= [^\\n]*");
127
+ lines.push('sp ::= " "+');
128
+ lines.push('wsopt ::= " "*');
129
+ lines.push("");
130
+ return lines.join("\n");
131
+ }
package/src/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // `exports` map; individual modules stay importable by their own path too.
5
5
 
6
6
  export { buildSite } from "./builder.js";
7
+ export { directiveCatalog, llmsText } from "./catalog.js";
7
8
 
8
9
  export {
9
10
  compileDocument,