@dogsbay/format-starlight 0.2.0-beta.12 → 0.2.0-beta.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,869 @@
1
+ /**
2
+ * JSX expressions over partial props → Jinja (template pages,
3
+ * plans/page-templates.md Phase 1).
4
+ *
5
+ * Cloudflare partials read their parameters as `props.x` inside MDX
6
+ * expressions: `{props.x}`, `{props.a === "b" ? "c" : props.d}`,
7
+ * `{props.a === "b" && (<p>…</p>)}`, `{props.x ?? "default"}`, and
8
+ * `href={`/x/${props.y}/`}` in attributes. This module translates the subset
9
+ * that corpus actually uses into Jinja:
10
+ *
11
+ * {props.x} → {{ x }}
12
+ * {props.x ?? "d"} → {{ x | default("d") }}
13
+ * {A ? B : C} (text position) → {% if A %}B{% else %}C{% endif %}
14
+ * {A && B} (text position) → {% if A %}B{% endif %}
15
+ * attr={EXPR} → attr="{{ EXPR }}" (or the literal text)
16
+ *
17
+ * The expression grammar is deliberately small: `props.name`, string /
18
+ * number / boolean literals, template literals, `=== == !== != && || ! ??`,
19
+ * `?:`, parentheses, and `.includes(…)`. Anything else — a function call,
20
+ * an arrow function, an object literal, another identifier — is REFUSED:
21
+ * the original `{…}` stays byte-for-byte and is counted, so a conversion
22
+ * never produces a plausible wrong template (the format-vitepress rule).
23
+ */
24
+ export function newStats() {
25
+ return { converted: 0, refused: 0, refusedSamples: [], names: new Set(), markdownInlined: 0 };
26
+ }
27
+ class Refuse extends Error {
28
+ }
29
+ const OPS = ["===", "!==", "==", "!=", "&&", "||", "??", "?.", "?", ":", "!", "(", ")", ".", ","];
30
+ function tokenize(src) {
31
+ const out = [];
32
+ let i = 0;
33
+ while (i < src.length) {
34
+ const c = src[i];
35
+ if (/\s/.test(c)) {
36
+ i++;
37
+ continue;
38
+ }
39
+ if (c === '"' || c === "'") {
40
+ let j = i + 1;
41
+ let v = "";
42
+ while (j < src.length && src[j] !== c) {
43
+ if (src[j] === "\\" && j + 1 < src.length) {
44
+ v += src[j + 1];
45
+ j += 2;
46
+ }
47
+ else
48
+ v += src[j++];
49
+ }
50
+ if (j >= src.length)
51
+ throw new Refuse("unterminated string");
52
+ out.push({ t: "str", v });
53
+ i = j + 1;
54
+ continue;
55
+ }
56
+ if (c === "`") {
57
+ const parts = [];
58
+ let j = i + 1;
59
+ let buf = "";
60
+ while (j < src.length && src[j] !== "`") {
61
+ if (src[j] === "$" && src[j + 1] === "{") {
62
+ const end = matchBrace(src, j + 1);
63
+ if (end < 0)
64
+ throw new Refuse("unterminated ${");
65
+ if (buf)
66
+ parts.push(buf);
67
+ buf = "";
68
+ parts.push(parseExpr(src.slice(j + 2, end)));
69
+ j = end + 1;
70
+ }
71
+ else if (src[j] === "\\" && j + 1 < src.length) {
72
+ buf += src[j + 1];
73
+ j += 2;
74
+ }
75
+ else
76
+ buf += src[j++];
77
+ }
78
+ if (j >= src.length)
79
+ throw new Refuse("unterminated template literal");
80
+ if (buf)
81
+ parts.push(buf);
82
+ out.push({ t: "tpl", parts });
83
+ i = j + 1;
84
+ continue;
85
+ }
86
+ if (c === "<") {
87
+ // A JSX value (in a branch position): take it whole, balanced by tags.
88
+ const end = jsxEnd(src, i);
89
+ out.push({ t: "jsx", v: src.slice(i, end) });
90
+ i = end;
91
+ continue;
92
+ }
93
+ if (/[0-9]/.test(c)) {
94
+ const m = /^[0-9]+(\.[0-9]+)?/.exec(src.slice(i));
95
+ out.push({ t: "num", v: m[0] });
96
+ i += m[0].length;
97
+ continue;
98
+ }
99
+ if (/[A-Za-z_$]/.test(c)) {
100
+ const m = /^[A-Za-z_$][\w$]*/.exec(src.slice(i));
101
+ out.push({ t: "id", v: m[0] });
102
+ i += m[0].length;
103
+ continue;
104
+ }
105
+ const op = OPS.find((o) => src.startsWith(o, i));
106
+ if (!op)
107
+ throw new Refuse(`unsupported character '${c}'`);
108
+ out.push({ t: "op", v: op });
109
+ i += op.length;
110
+ }
111
+ out.push({ t: "end" });
112
+ return out;
113
+ }
114
+ /**
115
+ * End index (exclusive) of a JSX element or fragment starting at `i`.
116
+ *
117
+ * A tag ends at the first `>` OUTSIDE its attribute values: Cloudflare's
118
+ * `<Markdown text={`… **Insights** > **Network health** …`} />` carries `>`
119
+ * in a template literal, and ending there refused ~40 partial conditionals
120
+ * (their `<Markdown>` then leaked as raw tags). Braced expressions in
121
+ * children (`{" "}`, `{props.x}`) are skipped whole for the same reason.
122
+ */
123
+ function jsxEnd(src, i) {
124
+ let depth = 0;
125
+ let j = i;
126
+ const skipBraced = (at) => {
127
+ const close = matchBrace(src, at);
128
+ if (close < 0)
129
+ throw new Refuse("unbalanced JSX expression");
130
+ return close + 1;
131
+ };
132
+ while (j < src.length) {
133
+ if (src[j] === "<") {
134
+ const closing = src[j + 1] === "/";
135
+ let k = j + 1;
136
+ for (;;) {
137
+ if (k >= src.length)
138
+ throw new Refuse("unterminated JSX");
139
+ const ch = src[k];
140
+ if (ch === "{")
141
+ k = skipBraced(k);
142
+ else if (ch === '"' || ch === "'") {
143
+ const q = src.indexOf(ch, k + 1);
144
+ if (q < 0)
145
+ throw new Refuse("unterminated JSX attribute");
146
+ k = q + 1;
147
+ }
148
+ else if (ch === ">")
149
+ break;
150
+ else
151
+ k++;
152
+ }
153
+ const selfClosing = src[k - 1] === "/";
154
+ if (closing)
155
+ depth--;
156
+ else if (!selfClosing)
157
+ depth++;
158
+ j = k + 1;
159
+ if (depth === 0)
160
+ return j;
161
+ continue;
162
+ }
163
+ if (depth === 0)
164
+ break;
165
+ if (src[j] === "{") {
166
+ j = skipBraced(j);
167
+ continue;
168
+ }
169
+ j++;
170
+ }
171
+ if (depth !== 0)
172
+ throw new Refuse("unbalanced JSX");
173
+ return j;
174
+ }
175
+ /** `bare`: identifiers that ARE props (a component's own destructured names), read as `props.<name>`. */
176
+ function parseExpr(src, bare) {
177
+ const toks = tokenize(src);
178
+ let p = 0;
179
+ const peek = () => toks[p];
180
+ const isOp = (v) => peek().t === "op" && peek().v === v;
181
+ const eat = (v) => {
182
+ if (!isOp(v))
183
+ throw new Refuse(`expected '${v}'`);
184
+ p++;
185
+ };
186
+ const primary = () => {
187
+ const t = toks[p++];
188
+ switch (t.t) {
189
+ case "str":
190
+ return { k: "str", v: t.v };
191
+ case "num":
192
+ return { k: "num", v: t.v };
193
+ case "tpl":
194
+ return { k: "tpl", parts: t.parts };
195
+ case "jsx":
196
+ return { k: "jsx", v: t.v };
197
+ case "id": {
198
+ if (t.v === "true" || t.v === "false")
199
+ return { k: "bool", v: t.v === "true" };
200
+ if (t.v === "null" || t.v === "undefined")
201
+ return { k: "none" };
202
+ if (bare?.has(t.v)) {
203
+ if (isOp(".") || isOp("?.") || isOp("("))
204
+ throw new Refuse("member access or call on a prop");
205
+ return { k: "prop", name: t.v };
206
+ }
207
+ // A component template has no `props` of its own: its names are `bare`.
208
+ if (t.v !== "props" || bare)
209
+ throw new Refuse(`identifier '${t.v}'`);
210
+ if (!(isOp(".") || isOp("?.")))
211
+ throw new Refuse("bare props");
212
+ p++;
213
+ const name = toks[p++];
214
+ if (name.t !== "id")
215
+ throw new Refuse("props.<name>");
216
+ let e = { k: "prop", name: name.v };
217
+ // props.x.includes(y) → y in x
218
+ if ((isOp(".") || isOp("?.")) && toks[p + 1]?.t === "id" && toks[p + 1].v === "includes") {
219
+ p += 2;
220
+ eat("(");
221
+ const needle = expr();
222
+ eat(")");
223
+ e = { k: "in", needle, hay: e };
224
+ }
225
+ if (isOp(".") || isOp("?.") || isOp("("))
226
+ throw new Refuse("member access or call beyond props.<name>");
227
+ return e;
228
+ }
229
+ case "op":
230
+ if (t.v === "(") {
231
+ const e = expr();
232
+ eat(")");
233
+ return e;
234
+ }
235
+ if (t.v === "!")
236
+ return { k: "not", e: primary() };
237
+ }
238
+ throw new Refuse("unexpected token");
239
+ };
240
+ const equality = () => {
241
+ let l = primary();
242
+ while (isOp("===") || isOp("==") || isOp("!==") || isOp("!=")) {
243
+ const op = toks[p++].v.startsWith("!") ? "!=" : "==";
244
+ l = { k: "bin", op, l, r: primary() };
245
+ }
246
+ return l;
247
+ };
248
+ const and = () => {
249
+ let l = equality();
250
+ while (isOp("&&")) {
251
+ p++;
252
+ l = { k: "bin", op: "and", l, r: equality() };
253
+ }
254
+ return l;
255
+ };
256
+ const or = () => {
257
+ let l = and();
258
+ while (isOp("||") || isOp("??")) {
259
+ const op = toks[p++].v === "??" ? "default" : "or";
260
+ l = { k: "bin", op, l, r: and() };
261
+ }
262
+ return l;
263
+ };
264
+ function expr() {
265
+ const test = or();
266
+ if (isOp("?")) {
267
+ p++;
268
+ const then = expr();
269
+ eat(":");
270
+ return { k: "cond", test, then, else: expr() };
271
+ }
272
+ return test;
273
+ }
274
+ const e = expr();
275
+ if (peek().t !== "end")
276
+ throw new Refuse("trailing input");
277
+ return e;
278
+ }
279
+ // ─── Jinja emission ───────────────────────────────────────────────────────
280
+ /**
281
+ * A Jinja string literal that needs no backslash escape for quotes.
282
+ *
283
+ * The directive text passes through the Markdown parser before it reaches
284
+ * the template engine, and Markdown reads `\"` as an escaped quote — it
285
+ * drops the backslash, and `"a \"b\" c"` arrived as `"a "b" c"`, a syntax
286
+ * error (the first Cloudflare conversion run failed on exactly this). So a
287
+ * value is quoted with whichever quote it does not contain, or split and
288
+ * joined with `~` when it contains both. Newlines and tabs stay `\n`/`\t`:
289
+ * Markdown leaves a backslash before a letter alone. A literal backslash
290
+ * cannot be written safely at all, so it is refused.
291
+ */
292
+ function jinjaString(v) {
293
+ if (v.includes("\\"))
294
+ throw new Refuse("a backslash in a string value");
295
+ const body = (x) => x.replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
296
+ if (!v.includes('"'))
297
+ return `"${body(v)}"`;
298
+ if (!v.includes("'"))
299
+ return `'${body(v)}'`;
300
+ return v.split('"').map((part) => `"${body(part)}"`).join(` ~ '"' ~ `);
301
+ }
302
+ /** A value expression in Jinja source. JSX values are not values. */
303
+ function value(e, names) {
304
+ switch (e.k) {
305
+ case "prop":
306
+ names.add(e.name);
307
+ return e.name;
308
+ case "str":
309
+ return jinjaString(e.v);
310
+ case "num":
311
+ return e.v;
312
+ case "bool":
313
+ return e.v ? "true" : "false";
314
+ case "none":
315
+ return "none";
316
+ case "tpl":
317
+ return e.parts.length === 0 ? '""' : e.parts.map((x) => (typeof x === "string" ? jinjaString(x) : `(${value(x, names)})`)).join(" ~ ");
318
+ case "not":
319
+ return `not (${value(e.e, names)})`;
320
+ case "in":
321
+ return `(${value(e.needle, names)}) in (${value(e.hay, names)})`;
322
+ case "bin":
323
+ if (e.op === "default")
324
+ return `(${value(e.l, names)}) | default(${value(e.r, names)})`;
325
+ return `(${value(e.l, names)}) ${e.op} (${value(e.r, names)})`;
326
+ case "cond":
327
+ return `(${value(e.then, names)}) if (${value(e.test, names)}) else (${value(e.else, names)})`;
328
+ case "jsx":
329
+ throw new Refuse("JSX in a value position");
330
+ }
331
+ }
332
+ /** A directive's source, as a token when a table is in use. */
333
+ function emit(src, stats) {
334
+ return stats.table ? stats.table.token(src) : src;
335
+ }
336
+ /** Output for a branch of a text-position conditional: literal text, JSX (converted recursively), or a value. */
337
+ function branch(e, stats, layout) {
338
+ if (e.k === "str")
339
+ return e.v;
340
+ if (e.k === "jsx") {
341
+ const inner = e.v.startsWith("<>") && e.v.endsWith("</>") ? e.v.slice(2, -3) : e.v;
342
+ const converted = convertText(layout.block ? dedent(inner) : inner, stats);
343
+ return layout.block ? converted.replace(/^\s*\n|\n\s*$/g, "") : converted;
344
+ }
345
+ if (e.k === "cond")
346
+ return statement(e, stats, layout);
347
+ if (e.k === "bin" && e.op === "and" && (e.r.k === "jsx" || e.r.k === "str"))
348
+ return statement(e, stats, layout);
349
+ return emit(`{{ ${value(e, stats.names)} }}`, stats);
350
+ }
351
+ /**
352
+ * Remove the common leading indentation of a JSX branch body. In JSX it
353
+ * means nothing; in Markdown four spaces make an indented CODE block, and a
354
+ * branch of `<strong>`/`<ul>` markup came out as a `plaintext` code sample.
355
+ */
356
+ function dedent(s) {
357
+ const lines = s.split("\n");
358
+ // The first line is left out of the minimum: it follows `(` or `&&` on
359
+ // the expression's own line, so its column says nothing about the body's
360
+ // (`{props.a && (<div>\n body` must still lose the 8).
361
+ const widths = lines.slice(1).filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)[0].length);
362
+ const min = widths.length ? Math.min(...widths) : 0;
363
+ return min ? lines.map((l) => l.slice(Math.min(min, /^[ \t]*/.exec(l)[0].length))).join("\n") : s;
364
+ }
365
+ /**
366
+ * A text-position expression: `{% if %}` for conditionals, `{{ }}` for
367
+ * values. A block (multi-line) conditional puts each directive on its own
368
+ * line with blank lines around it, at the expression's indent — so no
369
+ * Markdown block (an HTML block, an unmapped component) can swallow the
370
+ * `{% endif %}` that follows its body.
371
+ */
372
+ function statement(e, stats, layout) {
373
+ const sep = layout.block ? `\n\n${layout.indent}` : "";
374
+ const d = (src) => `${sep}${emit(src, stats)}${sep}`;
375
+ // No whitespace control: a conditional on a list or tab marker line is
376
+ // handled after rendering (TemplatePages joins an emptied marker line with
377
+ // its content). Trimming after the tags ate the NEXT line's indent whenever
378
+ // a conditional rendered nothing, and a tabs closer lost column 0.
379
+ if (e.k === "cond") {
380
+ const tail = e.else.k === "cond" ? `${d("{% else %}")}${statement(e.else, stats, layout)}` : `${d("{% else %}")}${branch(e.else, stats, layout)}`;
381
+ return `${d(`{% if ${value(e.test, stats.names)} %}`)}${branch(e.then, stats, layout)}${tail}${d("{% endif %}")}`;
382
+ }
383
+ if (e.k === "bin" && e.op === "and" && (e.r.k === "jsx" || e.r.k === "str")) {
384
+ return `${d(`{% if ${value(e.l, stats.names)} %}`)}${branch(e.r, stats, layout)}${d("{% endif %}")}`;
385
+ }
386
+ return emit(`{{ ${value(e, stats.names)} }}`, stats);
387
+ }
388
+ /** An attribute value: literal text when constant, else a `{{ }}` in quotes. */
389
+ function attribute(e, stats) {
390
+ const names = stats.names;
391
+ if (e.k === "str")
392
+ return JSON.stringify(e.v);
393
+ if (e.k === "tpl" && e.parts.every((x) => typeof x === "string"))
394
+ return JSON.stringify(e.parts.join(""));
395
+ if (e.k === "tpl") {
396
+ return JSON.stringify(e.parts.map((x) => (typeof x === "string" ? x : emit(`{{ ${value(x, names)} }}`, stats))).join(""));
397
+ }
398
+ return JSON.stringify(emit(`{{ ${value(e, names)} }}`, stats));
399
+ }
400
+ /**
401
+ * Cloudflare's `<Markdown text={…} />` renders its text as Markdown. When the
402
+ * text is a string or template literal (the partials' shape), the tag is
403
+ * replaced by the text itself, dedented, with `${props.x}` as `{{ x }}`.
404
+ * Unmapped, it was dropped in both import modes. Returns undefined when the
405
+ * tag is not that shape.
406
+ */
407
+ function markdownTag(src, at, stats) {
408
+ // `text="…"` (a plain attribute): its value IS the Markdown. Only the
409
+ // braced form was read, so `{ !props.x && <Markdown text="…" /> }` leaked.
410
+ const quoted = /^<Markdown\s+text=(?:"([^"]*)"|'([^']*)')((?:\s+[\w:-]+(?:=(?:"[^"]*"|'[^']*'|\{[^{}]*\}))?)*)\s*\/>/.exec(src.slice(at));
411
+ if (quoted) {
412
+ stats.markdownInlined++;
413
+ return { end: at + quoted[0].length, text: (quoted[1] ?? quoted[2]).trim() };
414
+ }
415
+ const head = /^<Markdown\s+text=\{/.exec(src.slice(at));
416
+ if (!head)
417
+ return undefined;
418
+ const open = at + head[0].length - 1;
419
+ const close = matchBrace(src, open);
420
+ if (close < 0)
421
+ return undefined;
422
+ const tagEnd = src.indexOf("/>", close);
423
+ if (tagEnd < 0 || /[<>]/.test(src.slice(close + 1, tagEnd)))
424
+ return undefined;
425
+ let e;
426
+ try {
427
+ e = parseExpr(src.slice(open + 1, close));
428
+ }
429
+ catch (err) {
430
+ if (err instanceof Refuse)
431
+ return undefined;
432
+ throw err;
433
+ }
434
+ // `text={props.x}` (and `props.x || ""`): the parameter's own Markdown, as
435
+ // a value. It is Markdown already, and the rendered page is parsed as
436
+ // Markdown, so `{{ x }}` renders as the component did. 150+ of these leaked
437
+ // as raw tags in partials.
438
+ if (e.k !== "str" && e.k !== "tpl") {
439
+ // Only what prints TEXT: a prop, or `a || "b"` / `a ?? "b"`. `a && b` and
440
+ // comparisons would print `false`/`true` (Jinja returns the operand).
441
+ const printsText = (x) => x.k === "prop" || x.k === "str" || (x.k === "bin" && (x.op === "or" || x.op === "default") && printsText(x.l) && printsText(x.r));
442
+ if (!printsText(e))
443
+ return undefined;
444
+ let v;
445
+ try {
446
+ v = value(e, stats.names);
447
+ }
448
+ catch (err) {
449
+ if (err instanceof Refuse)
450
+ return undefined;
451
+ throw err;
452
+ }
453
+ stats.markdownInlined++;
454
+ return { end: tagEnd + 2, text: emit(`{{ ${v} }}`, stats) };
455
+ }
456
+ const raw = e.k === "str" ? e.v : e.parts.map((x) => (typeof x === "string" ? x : emit(`{{ ${value(x, stats.names)} }}`, stats))).join("");
457
+ const lines = raw.replace(/^\n+|\s+$/g, "").split("\n");
458
+ const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^\s*/.exec(l)[0].length));
459
+ // A tag on its own indented line (inside a JSX branch): its continuation
460
+ // lines take that indent too, as the first line does by standing after it.
461
+ // At column 0 they broke the branch dedent — a table's first row kept a
462
+ // tab, its other rows had none, and the table was lost.
463
+ const lineStart = src.lastIndexOf("\n", at - 1) + 1;
464
+ const lead = /^[ \t]*$/.test(src.slice(lineStart, at)) ? src.slice(lineStart, at) : "";
465
+ stats.markdownInlined++;
466
+ return {
467
+ end: tagEnd + 2,
468
+ text: lines.map((l, n) => (n && l.trim() ? lead : "") + l.slice(Number.isFinite(indent) ? indent : 0)).join("\n"),
469
+ };
470
+ }
471
+ // ─── Scanning the MDX body ────────────────────────────────────────────────
472
+ /** Index of the `}` matching the `{` at `open`, skipping strings, template literals and JSX values. */
473
+ function matchBrace(src, open) {
474
+ let depth = 0;
475
+ for (let i = open; i < src.length; i++) {
476
+ const c = src[i];
477
+ // Comments: `{/* note, <Aside> below */}` is not JSX, and `{/* don't */}`
478
+ // has no string in it.
479
+ if (c === "/" && src[i + 1] === "*") {
480
+ const end = src.indexOf("*/", i + 2);
481
+ if (end < 0)
482
+ return -1;
483
+ i = end + 1;
484
+ continue;
485
+ }
486
+ if (c === "/" && src[i + 1] === "/" && i > open) {
487
+ const nl = src.indexOf("\n", i);
488
+ if (nl < 0)
489
+ return -1;
490
+ i = nl;
491
+ continue;
492
+ }
493
+ if (c === '"' || c === "'" || c === "`") {
494
+ const q = c;
495
+ i++;
496
+ while (i < src.length && src[i] !== q) {
497
+ if (src[i] === "\\")
498
+ i++;
499
+ else if (q === "`" && src[i] === "$" && src[i + 1] === "{") {
500
+ const end = matchBrace(src, i + 1);
501
+ if (end < 0)
502
+ return -1;
503
+ i = end;
504
+ }
505
+ i++;
506
+ }
507
+ continue;
508
+ }
509
+ // JSX in a value position (`&& (<>…</>)`, `? <p>…</p> :`): its TEXT is
510
+ // not JavaScript. An apostrophe there ("Cloudflare's") was read as a
511
+ // string opener, the brace never closed, and the rest of the partial was
512
+ // copied through unconverted (its `<Markdown>`/`<AnchorHeading>` leaked).
513
+ if (c === "<" && /[A-Za-z>]/.test(src[i + 1] ?? "") && /[(?:,&|{[]$/.test(src.slice(open, i).trimEnd())) {
514
+ try {
515
+ i = jsxEnd(src, i) - 1;
516
+ }
517
+ catch {
518
+ return -1;
519
+ }
520
+ continue;
521
+ }
522
+ if (c === "{")
523
+ depth++;
524
+ else if (c === "}") {
525
+ depth--;
526
+ if (depth === 0)
527
+ return i;
528
+ }
529
+ }
530
+ return -1;
531
+ }
532
+ /**
533
+ * Convert every `{…}` over props in a text span (no code fences). Inline code
534
+ * spans are skipped: `{props.x}` inside backticks is literal in MDX too.
535
+ * `<Render …/>` tags are left alone — their `params={{…}}` belong to the
536
+ * Render conversion.
537
+ */
538
+ function convertText(src, stats) {
539
+ let out = "";
540
+ let i = 0;
541
+ while (i < src.length) {
542
+ const c = src[i];
543
+ if (c === "`") {
544
+ const run = /^`+/.exec(src.slice(i))[0];
545
+ const close = src.indexOf(run, i + run.length);
546
+ if (close < 0) {
547
+ out += src.slice(i);
548
+ break;
549
+ }
550
+ out += src.slice(i, close + run.length);
551
+ i = close + run.length;
552
+ continue;
553
+ }
554
+ if (src.startsWith("<Markdown", i)) {
555
+ const md = markdownTag(src, i, stats);
556
+ if (md) {
557
+ out += md.text;
558
+ i = md.end;
559
+ continue;
560
+ }
561
+ }
562
+ if (src.startsWith("<Render", i)) {
563
+ const end = src.indexOf("/>", i);
564
+ if (end >= 0) {
565
+ out += src.slice(i, end + 2);
566
+ i = end + 2;
567
+ continue;
568
+ }
569
+ }
570
+ if (c !== "{") {
571
+ out += c;
572
+ i++;
573
+ continue;
574
+ }
575
+ const close = matchBrace(src, i);
576
+ if (close < 0) {
577
+ out += src.slice(i);
578
+ break;
579
+ }
580
+ const whole = src.slice(i, close + 1);
581
+ const inner = whole.slice(1, -1);
582
+ if (!/\bprops\b/.test(inner) || inner.trim().startsWith("/*")) {
583
+ out += whole;
584
+ i = close + 1;
585
+ continue;
586
+ }
587
+ // Attribute position: `name={…}` inside an open JSX tag (`<a href={…}`).
588
+ // Prose like `CLOUDFLARE_ACCOUNT_ID={props.id}` is NOT an attribute: it
589
+ // would gain literal quotes the source never rendered.
590
+ const inTag = /<[A-Za-z][^<>]*$/.test(out);
591
+ const attr = inTag ? /([A-Za-z_:][\w:.-]*)\s*=\s*$/.exec(out) : null;
592
+ try {
593
+ const e = parseExpr(inner);
594
+ if (attr) {
595
+ out = out.slice(0, out.length - attr[0].length) + `${attr[1]}=` + attribute(e, stats);
596
+ }
597
+ else {
598
+ const lineStart = out.lastIndexOf("\n") + 1;
599
+ const indent = /^[ \t]*/.exec(out.slice(lineStart))[0];
600
+ // A conditional alone on its line with a JSX branch is a BLOCK too:
601
+ // `{ props.p === "x" && <><AnchorHeading … /></> }` kept its heading
602
+ // glued between `{% if %}` and `{% endif %}`, where it could not be
603
+ // a heading and leaked as a tag.
604
+ const alone = out.slice(lineStart) === indent && /^[ \t]*(\n|$)/.test(src.slice(close + 1));
605
+ // Only a BLOCK-level branch: `<Markdown text={`- item`}>` after a list
606
+ // item, or `<em>` mid-paragraph, must stay on the line (blank lines
607
+ // made the list loose and split the paragraph — review).
608
+ const blockJsx = /^(?:<>\s*)?<(?:AnchorHeading|Aside|Details|Tabs|Steps|CardGrid|LinkTitleCard|LinkCard|LinkButton|Card|Code|h[1-6]|table|ul|ol|div)\b/;
609
+ const jsxBranch = (x) => (x.k === "jsx" && blockJsx.test(x.v.trim())) ||
610
+ (x.k === "bin" && x.op === "and" && jsxBranch(x.r)) ||
611
+ (x.k === "cond" && (jsxBranch(x.then) || jsxBranch(x.else)));
612
+ out += statement(e, stats, { block: inner.includes("\n") || (alone && jsxBranch(e)), indent });
613
+ }
614
+ stats.converted++;
615
+ }
616
+ catch (err) {
617
+ if (!(err instanceof Refuse))
618
+ throw err;
619
+ stats.refused++;
620
+ if (stats.refusedSamples.length < 20)
621
+ stats.refusedSamples.push(whole.replace(/\s+/g, " ").slice(0, 160));
622
+ out += whole;
623
+ }
624
+ i = close + 1;
625
+ }
626
+ return out;
627
+ }
628
+ /**
629
+ * Convert a partial's MDX body. Fenced code blocks are copied verbatim;
630
+ * everything else goes through `convertText`.
631
+ */
632
+ export function jsxPropsToJinja(body, stats) {
633
+ // Directive tokens are restored in the tree (template-tokens.ts).
634
+ const lines = body.split("\n");
635
+ const out = [];
636
+ let fence = null;
637
+ let buf = [];
638
+ const flush = () => {
639
+ if (buf.length)
640
+ out.push(convertText(buf.join("\n"), stats));
641
+ buf = [];
642
+ };
643
+ for (const line of lines) {
644
+ const m = /^\s*(`{3,}|~{3,})/.exec(line);
645
+ if (fence) {
646
+ out.push(line);
647
+ if (m && m[1][0] === fence[0] && m[1].length >= fence.length && line.trim() === m[1])
648
+ fence = null;
649
+ continue;
650
+ }
651
+ if (m) {
652
+ flush();
653
+ fence = m[1];
654
+ out.push(line);
655
+ continue;
656
+ }
657
+ buf.push(line);
658
+ }
659
+ flush();
660
+ return out.join("\n");
661
+ }
662
+ /**
663
+ * A JS literal from `params={{ … }}` as a Jinja expression, or undefined when
664
+ * it is outside the subset (the caller then inlines the partial as before).
665
+ */
666
+ export function jsValueToJinja(src, names) {
667
+ try {
668
+ const trimmed = src.trim();
669
+ // Arrays of literals: ["a", "b"] → ["a", "b"]
670
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
671
+ const items = splitTopLevel(trimmed.slice(1, -1));
672
+ const vals = items.filter((x) => x.trim()).map((x) => jsValueToJinja(x, names));
673
+ return vals.some((v) => v === undefined) ? undefined : `[${vals.join(", ")}]`;
674
+ }
675
+ const e = parseExpr(trimmed);
676
+ if (e.k === "jsx")
677
+ return undefined;
678
+ return value(e, names ?? new Set());
679
+ }
680
+ catch (err) {
681
+ if (err instanceof Refuse)
682
+ return undefined;
683
+ throw err;
684
+ }
685
+ }
686
+ /** Split `a, b, "c, d"` at top-level commas (strings, brackets and braces respected). */
687
+ export function splitTopLevel(src) {
688
+ const parts = [];
689
+ let depth = 0;
690
+ let cur = "";
691
+ let q = null;
692
+ for (let i = 0; i < src.length; i++) {
693
+ const c = src[i];
694
+ if (q) {
695
+ cur += c;
696
+ if (c === "\\")
697
+ cur += src[++i] ?? "";
698
+ else if (c === q)
699
+ q = null;
700
+ continue;
701
+ }
702
+ if (c === '"' || c === "'" || c === "`")
703
+ q = c;
704
+ else if (c === "{" || c === "[" || c === "(")
705
+ depth++;
706
+ else if (c === "}" || c === "]" || c === ")")
707
+ depth--;
708
+ else if (c === "," && depth === 0) {
709
+ parts.push(cur);
710
+ cur = "";
711
+ continue;
712
+ }
713
+ cur += c;
714
+ }
715
+ if (cur.trim())
716
+ parts.push(cur);
717
+ return parts;
718
+ }
719
+ // ─── Static rendering of a component template ─────────────────────────────
720
+ /**
721
+ * Render an Astro component's TEMPLATE for known props — read, never run:
722
+ * `{cond && (<p>…</p>)}`, `{a === "x" ? (A) : (B)}`, `attr={cond ? "a" : "b"}`,
723
+ * `{"text"}`, fragments, comments — the JS subset `parseExpr` reads, with
724
+ * the component's own prop names (`cli`, not `props.cli`). `inline` may
725
+ * replace a self-closing sub-component with its own rendering. Undefined
726
+ * when anything is outside the subset (the caller leaves the tag reported).
727
+ */
728
+ export function renderComponentTemplate(template, props, inline) {
729
+ // Prop names are resolved by the TOKENIZER (a regex rename also rewrote
730
+ // the word in a JSX branch's text).
731
+ const names = new Set(Object.keys(props));
732
+ const truthy = (v) => !(v === undefined || v === null || v === false || v === 0 || v === "");
733
+ const evalExpr = (e) => {
734
+ switch (e.k) {
735
+ case "prop":
736
+ return props[e.name];
737
+ case "str":
738
+ return e.v;
739
+ case "num":
740
+ return Number(e.v);
741
+ case "bool":
742
+ return e.v;
743
+ case "none":
744
+ return undefined;
745
+ case "tpl":
746
+ return e.parts.map((x) => (typeof x === "string" ? x : String(evalExpr(x) ?? ""))).join("");
747
+ case "jsx":
748
+ return { jsx: e.v };
749
+ case "not":
750
+ return !truthy(evalExpr(e.e));
751
+ case "in": {
752
+ const hay = evalExpr(e.hay);
753
+ return Array.isArray(hay) || typeof hay === "string" ? hay.includes(evalExpr(e.needle)) : false;
754
+ }
755
+ case "cond":
756
+ return truthy(evalExpr(e.test)) ? evalExpr(e.then) : evalExpr(e.else);
757
+ case "bin": {
758
+ const l = evalExpr(e.l);
759
+ if (e.op === "and")
760
+ return truthy(l) ? evalExpr(e.r) : l;
761
+ if (e.op === "or")
762
+ return truthy(l) ? l : evalExpr(e.r);
763
+ if (e.op === "default")
764
+ return l ?? evalExpr(e.r);
765
+ const r = evalExpr(e.r);
766
+ return e.op === "==" ? l === r : l !== r;
767
+ }
768
+ }
769
+ };
770
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
771
+ const render = (src, depth) => {
772
+ if (depth > 8)
773
+ throw new Refuse("nesting");
774
+ let out = "";
775
+ let i = 0;
776
+ while (i < src.length) {
777
+ const c = src[i];
778
+ if (c !== "{") {
779
+ out += c;
780
+ i++;
781
+ continue;
782
+ }
783
+ const close = matchBrace(src, i);
784
+ if (close < 0)
785
+ throw new Refuse("unbalanced");
786
+ const inner = src.slice(i + 1, close).trim();
787
+ i = close + 1;
788
+ if (/^\/\*[\s\S]*\*\/$/.test(inner))
789
+ continue;
790
+ const attr = /(?:^|\s)([A-Za-z_:][\w:.-]*)\s*=\s*$/.exec(out);
791
+ // Inside an open tag, quoted values skipped (a `>` in `title="a>b"` is not the tag's end).
792
+ const inTag = /<[A-Za-z][\w:.-]*(?:\s+[^\s"'<>=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*\s+[^\s"'<>=]+\s*=\s*$/.test(out);
793
+ if (attr && !inTag && /<[A-Za-z][^<]*$/.test(out))
794
+ throw new Refuse("attribute position unclear");
795
+ const v = evalExpr(parseExpr(inner, names));
796
+ if (inTag && attr) {
797
+ // A falsy value drops the attribute, and the space before it.
798
+ if (attr[1].startsWith("set:"))
799
+ throw new Refuse("set: directive");
800
+ const sep = /^\s/.test(attr[0]) ? " " : "";
801
+ out = out.slice(0, out.length - attr[0].length);
802
+ // Astro drops an attribute only for false, null and undefined (0 and "" render).
803
+ if (v === true)
804
+ out += sep + attr[1];
805
+ else if (v !== false && v !== null && v !== undefined) {
806
+ if (typeof v === "object")
807
+ throw new Refuse("object attribute");
808
+ out += `${sep}${attr[1]}="${String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;")}"`;
809
+ }
810
+ continue;
811
+ }
812
+ if (v && typeof v === "object" && "jsx" in v) {
813
+ const jsx = String(v.jsx).replace(/^<>/, "").replace(/<\/>$/, "");
814
+ out += render(jsx, depth + 1);
815
+ }
816
+ else if (typeof v === "string" || typeof v === "number")
817
+ out += esc(String(v));
818
+ }
819
+ return out;
820
+ };
821
+ let html;
822
+ try {
823
+ html = render(template, 0);
824
+ }
825
+ catch (err) {
826
+ if (err instanceof Refuse)
827
+ return undefined;
828
+ throw err;
829
+ }
830
+ html = html.replace(/<\/?>/g, "");
831
+ if (!inline)
832
+ return html;
833
+ const tagRe = /<([A-Z]\w*)\b((?:[^<>"'{]|"[^"]*"|'[^']*'|\{[^{}]*\})*)\/>/g;
834
+ html = html.replace(tagRe, (whole, tag, attrStr) => {
835
+ const attrs = {};
836
+ for (const m of attrStr.matchAll(/([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g))
837
+ attrs[m[1]] = m[2] ?? m[3] ?? true;
838
+ const r = inline(tag, attrs);
839
+ if (r === undefined)
840
+ return whole;
841
+ return r;
842
+ });
843
+ return html;
844
+ }
845
+ /** `const { a, b = false } = Astro.props;` → the prop names and their defaults (literals only). */
846
+ export function componentPropDefaults(frontmatter) {
847
+ const m = /const\s*\{([^}]*)\}\s*=\s*Astro\.props/.exec(frontmatter);
848
+ if (!m)
849
+ return {};
850
+ const out = {};
851
+ for (const part of splitTopLevel(m[1])) {
852
+ const eq = part.indexOf("=");
853
+ const name = (eq < 0 ? part : part.slice(0, eq)).trim();
854
+ const def = eq < 0 ? undefined : part.slice(eq + 1).trim();
855
+ if (!/^\w+$/.test(name))
856
+ return undefined;
857
+ if (def === undefined) {
858
+ out[name] = undefined;
859
+ continue;
860
+ }
861
+ // A default it cannot read makes the whole component unreadable: a
862
+ // guessed `undefined` would render the wrong branch silently.
863
+ const lit = def === "false" ? false : def === "true" ? true : def === "null" ? null : /^"[^"]*"$|^'[^']*'$/.test(def) ? def.slice(1, -1) : /^-?\d+(\.\d+)?$/.test(def) ? Number(def) : Symbol.for("unreadable");
864
+ if (typeof lit === "symbol")
865
+ return undefined;
866
+ out[name] = lit;
867
+ }
868
+ return out;
869
+ }