@stll/template-conditions 0.3.0 → 0.4.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.
- package/README.md +1 -1
- package/dist/index.d.ts +183 -70
- package/dist/index.js +681 -157
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -192,11 +192,12 @@ const COMPARE_SYMBOL_TO_OP = {
|
|
|
192
192
|
};
|
|
193
193
|
const escapeRegexLiteral = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
194
194
|
const COMPARE_SYMBOL_PATTERN = Object.keys(COMPARE_SYMBOL_TO_OP).sort((left, right) => right.length - left.length).map(escapeRegexLiteral).join("|");
|
|
195
|
-
const NON_STRING_TOKEN_RE = new RegExp(String.raw`(?<token>${COMPARE_SYMBOL_PATTERN}
|
|
195
|
+
const NON_STRING_TOKEN_RE = new RegExp(String.raw`(?<token>${COMPARE_SYMBOL_PATTERN}|and\b|or\b|not\b|in\b|is\b|defined\b|[()]|-?\d[\p{N}_.]*|[\p{L}\p{N}_.]+(?:-[\p{L}\p{N}_.]+)*)`, "uy");
|
|
196
196
|
const STARTS_WITH_DIGIT_RE = /^-?\d/u;
|
|
197
197
|
const isCompareSymbol = (raw) => Object.hasOwn(COMPARE_SYMBOL_TO_OP, raw);
|
|
198
198
|
/**
|
|
199
|
-
* Scan a `"..."` literal starting at `expr[start]` (the opening
|
|
199
|
+
* Scan a `"..."` or `'...'` literal starting at `expr[start]` (the opening
|
|
200
|
+
* quote),
|
|
200
201
|
* honoring `\\`-escapes without interpreting them. A single linear pass:
|
|
201
202
|
* each character is visited at most once, so this is safe on adversarial
|
|
202
203
|
* input regardless of how many `"` or `\\` characters it contains.
|
|
@@ -206,9 +207,10 @@ const isCompareSymbol = (raw) => Object.hasOwn(COMPARE_SYMBOL_TO_OP, raw);
|
|
|
206
207
|
* closing quote simply closes at end of input.
|
|
207
208
|
*/
|
|
208
209
|
const scanString = (expr, start) => {
|
|
210
|
+
const quote = expr[start];
|
|
209
211
|
let i = start + 1;
|
|
210
212
|
while (i < expr.length) {
|
|
211
|
-
if (expr[i] ===
|
|
213
|
+
if (expr[i] === quote) return {
|
|
212
214
|
content: expr.slice(start + 1, i),
|
|
213
215
|
end: i + 1
|
|
214
216
|
};
|
|
@@ -222,8 +224,11 @@ const scanString = (expr, start) => {
|
|
|
222
224
|
const classifyNonString = (raw) => {
|
|
223
225
|
if (raw === "and") return { type: "and" };
|
|
224
226
|
if (raw === "or") return { type: "or" };
|
|
225
|
-
if (raw === "
|
|
226
|
-
if (raw === "
|
|
227
|
+
if (raw === "not") return { type: "not" };
|
|
228
|
+
if (raw === "in") return { type: "in" };
|
|
229
|
+
if (raw === "is") return { type: "is" };
|
|
230
|
+
if (raw === "defined") return { type: "defined" };
|
|
231
|
+
if (isCompareSymbol(raw)) return {
|
|
227
232
|
type: "op",
|
|
228
233
|
raw
|
|
229
234
|
};
|
|
@@ -238,11 +243,11 @@ const tokenize = (expr) => {
|
|
|
238
243
|
const tokens = [];
|
|
239
244
|
let pos = 0;
|
|
240
245
|
while (pos < expr.length) {
|
|
241
|
-
if (expr[pos] === "\"") {
|
|
246
|
+
if (expr[pos] === "\"" || expr[pos] === "'") {
|
|
242
247
|
const { content, end } = scanString(expr, pos);
|
|
243
248
|
tokens.push({
|
|
244
249
|
type: "string",
|
|
245
|
-
raw: content.replace(/\\"/gu, "
|
|
250
|
+
raw: content.replace(/\\(?<quote>["'])/gu, "$<quote>")
|
|
246
251
|
});
|
|
247
252
|
pos = end;
|
|
248
253
|
continue;
|
|
@@ -289,9 +294,9 @@ const operandFromToken = (token) => {
|
|
|
289
294
|
path: token.raw
|
|
290
295
|
};
|
|
291
296
|
};
|
|
292
|
-
/** `
|
|
293
|
-
*
|
|
294
|
-
const
|
|
297
|
+
/** `"x" in path` carries a literal payload (`string`), not an operand: render
|
|
298
|
+
* the left-hand token as a string. */
|
|
299
|
+
const membershipValue = (token) => {
|
|
295
300
|
const operand = operandFromToken(token);
|
|
296
301
|
return operand.type === "literal" ? String(operand.value) : token.raw;
|
|
297
302
|
};
|
|
@@ -358,6 +363,39 @@ const parseCondition = (expression) => {
|
|
|
358
363
|
}
|
|
359
364
|
pos += 1;
|
|
360
365
|
const left = operandFromToken(leftTok);
|
|
366
|
+
if (peek()?.type === "is") {
|
|
367
|
+
pos += 1;
|
|
368
|
+
const negated = peek()?.type === "not";
|
|
369
|
+
if (negated) pos += 1;
|
|
370
|
+
if (peek()?.type === "defined") pos += 1;
|
|
371
|
+
return {
|
|
372
|
+
type: "predicate",
|
|
373
|
+
operand: left,
|
|
374
|
+
op: negated ? "is_empty" : "is_not_empty"
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (peek()?.type === "in") {
|
|
378
|
+
pos += 1;
|
|
379
|
+
const rightTok = peek();
|
|
380
|
+
if (!rightTok || rightTok.type !== "value" && rightTok.type !== "string") return {
|
|
381
|
+
type: "predicate",
|
|
382
|
+
operand: left,
|
|
383
|
+
op: "is_truthy"
|
|
384
|
+
};
|
|
385
|
+
pos += 1;
|
|
386
|
+
const right = operandFromToken(rightTok);
|
|
387
|
+
if (right.type !== "path") return {
|
|
388
|
+
type: "predicate",
|
|
389
|
+
operand: right,
|
|
390
|
+
op: "is_truthy"
|
|
391
|
+
};
|
|
392
|
+
return {
|
|
393
|
+
type: "predicate",
|
|
394
|
+
operand: right,
|
|
395
|
+
op: "contains",
|
|
396
|
+
value: membershipValue(leftTok)
|
|
397
|
+
};
|
|
398
|
+
}
|
|
361
399
|
const opTok = peek();
|
|
362
400
|
if (opTok?.type !== "op") return {
|
|
363
401
|
type: "predicate",
|
|
@@ -372,12 +410,6 @@ const parseCondition = (expression) => {
|
|
|
372
410
|
op: "is_truthy"
|
|
373
411
|
};
|
|
374
412
|
pos += 1;
|
|
375
|
-
if (opTok.raw === "contains") return {
|
|
376
|
-
type: "predicate",
|
|
377
|
-
operand: left,
|
|
378
|
-
op: "contains",
|
|
379
|
-
value: containsValue(rightTok)
|
|
380
|
-
};
|
|
381
413
|
return {
|
|
382
414
|
type: "compare",
|
|
383
415
|
left,
|
|
@@ -387,51 +419,187 @@ const parseCondition = (expression) => {
|
|
|
387
419
|
};
|
|
388
420
|
return parseOr();
|
|
389
421
|
};
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region src/referenced-paths.ts
|
|
424
|
+
const operandsOf = (node) => node.type === "compare" ? [node.left, node.right] : [node.operand];
|
|
425
|
+
/** Every field path the tree reads, or `null` when an operand names something
|
|
426
|
+
* whose own references cannot be enumerated (a formula over other fields). */
|
|
427
|
+
const nodePaths = (node) => {
|
|
428
|
+
if (node.type === "group") {
|
|
429
|
+
const paths = [];
|
|
430
|
+
for (const child of node.children) {
|
|
431
|
+
const childPaths = nodePaths(child);
|
|
432
|
+
if (childPaths === null) return null;
|
|
433
|
+
paths.push(...childPaths);
|
|
434
|
+
}
|
|
435
|
+
return paths;
|
|
436
|
+
}
|
|
437
|
+
const paths = [];
|
|
438
|
+
for (const operand of operandsOf(node)) {
|
|
439
|
+
if (operand.type === "formula") return null;
|
|
440
|
+
if (operand.type === "path") paths.push(operand.path);
|
|
441
|
+
}
|
|
442
|
+
return paths;
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* Every field path an expression reads, in source order.
|
|
446
|
+
*
|
|
447
|
+
* `null` says the expression does not answer the question: it did not parse,
|
|
448
|
+
* or it reads an operand whose own references cannot be enumerated. A caller
|
|
449
|
+
* deciding what a condition depends on must not read that as "depends on
|
|
450
|
+
* nothing".
|
|
451
|
+
*/
|
|
452
|
+
const referencedConditionPaths = (expression) => {
|
|
453
|
+
const node = parseCondition(expression);
|
|
454
|
+
return node === null ? null : nodePaths(node);
|
|
455
|
+
};
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/markers.ts
|
|
458
|
+
/**
|
|
459
|
+
* Canonical grammar for the docxtpl dialect of Jinja — the single source of
|
|
460
|
+
* truth shared by the fill pipeline (api), the editor highlighter (folio), and
|
|
461
|
+
* the read-only preview (web).
|
|
462
|
+
*
|
|
463
|
+
* Two spans carry meaning: an output marker `{{ ... }}` prints a value, and a
|
|
464
|
+
* tag `{% ... %}` opens or closes a block. A tag may carry a docxtpl placement
|
|
465
|
+
* prefix — `{%p ... %}` owns its paragraph, `{%tr ... %}` owns its table row —
|
|
466
|
+
* and a bare tag is placed by the leniency rules the consumer applies.
|
|
467
|
+
*
|
|
468
|
+
* Add a new directive to `MarkerMeta` and teach {@link classifyMarker} to
|
|
469
|
+
* recognize it. Compile-time totality checks then require its ordered runtime
|
|
470
|
+
* value and its block/inline disposition, while exhaustive consumer switches
|
|
471
|
+
* fail until they handle it.
|
|
472
|
+
*
|
|
473
|
+
* No consumer should define its own marker regex. The low-level pattern
|
|
474
|
+
* factories below exist so handlers that mutate raw XML/text reuse the exact
|
|
475
|
+
* same literals; recognizers should prefer the high-level scanner.
|
|
476
|
+
*/
|
|
477
|
+
/** The loop properties addressable inside a `{% for %}` body. */
|
|
478
|
+
const LOOP_PROPERTIES = [
|
|
479
|
+
"index",
|
|
480
|
+
"index0",
|
|
481
|
+
"first",
|
|
482
|
+
"last",
|
|
483
|
+
"length"
|
|
484
|
+
];
|
|
390
485
|
const DIRECTIVE_KINDS = [
|
|
391
486
|
"placeholder",
|
|
392
487
|
"clause",
|
|
393
488
|
"num",
|
|
394
489
|
"ref",
|
|
395
|
-
"
|
|
396
|
-
"count",
|
|
490
|
+
"loop",
|
|
397
491
|
"if",
|
|
398
|
-
"
|
|
492
|
+
"elif",
|
|
399
493
|
"else",
|
|
400
494
|
"endif",
|
|
401
|
-
"
|
|
402
|
-
"
|
|
495
|
+
"for",
|
|
496
|
+
"endfor"
|
|
403
497
|
];
|
|
404
498
|
/** Directives that occupy their own paragraph, in canonical grammar order. */
|
|
405
499
|
const BLOCK_DIRECTIVE_KINDS = [
|
|
406
500
|
"if",
|
|
407
|
-
"
|
|
501
|
+
"elif",
|
|
408
502
|
"else",
|
|
409
503
|
"endif",
|
|
410
|
-
"
|
|
411
|
-
"
|
|
504
|
+
"for",
|
|
505
|
+
"endfor"
|
|
412
506
|
];
|
|
413
507
|
const isBlockDirectiveKind = (kind) => typeof kind === "string" && BLOCK_DIRECTIVE_KINDS.some((blockKind) => blockKind === kind);
|
|
414
|
-
/**
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
508
|
+
/** Callable forms inside `{{ }}` that address document machinery rather than a
|
|
509
|
+
* fill value. */
|
|
510
|
+
const FUNCTION_NAMES = [
|
|
511
|
+
"clause",
|
|
512
|
+
"num",
|
|
513
|
+
"ref"
|
|
514
|
+
];
|
|
515
|
+
/**
|
|
516
|
+
* Every filter the dialect accepts on a fillable value. The names are the
|
|
517
|
+
* grammar's; which manifest property each one writes is decided by the field
|
|
518
|
+
* catalogue in the api, which is total over BOTH this list and the manifest
|
|
519
|
+
* keys, so neither side can gain an entry the other has not decided.
|
|
520
|
+
*/
|
|
521
|
+
const FILTER_NAMES = [
|
|
522
|
+
"text",
|
|
523
|
+
"number",
|
|
524
|
+
"date",
|
|
525
|
+
"checkbox",
|
|
526
|
+
"select",
|
|
527
|
+
"options_from",
|
|
528
|
+
"label",
|
|
529
|
+
"hint",
|
|
530
|
+
"required",
|
|
531
|
+
"pattern",
|
|
532
|
+
"min",
|
|
533
|
+
"max",
|
|
534
|
+
"min_length",
|
|
535
|
+
"max_length",
|
|
536
|
+
"min_items",
|
|
537
|
+
"max_items",
|
|
538
|
+
"ai",
|
|
539
|
+
"lookup",
|
|
540
|
+
"matter",
|
|
541
|
+
"contact",
|
|
542
|
+
"party",
|
|
543
|
+
"attorney",
|
|
544
|
+
"firm",
|
|
545
|
+
"formula",
|
|
546
|
+
"condition"
|
|
547
|
+
];
|
|
548
|
+
const isFilterName = (value) => FILTER_NAMES.some((name) => name === value);
|
|
549
|
+
const isFunctionName = (value) => FUNCTION_NAMES.some((name) => name === value);
|
|
550
|
+
const SMART_DOUBLE_QUOTES = /[“”„«»]/gu;
|
|
551
|
+
const SMART_SINGLE_QUOTES = /[‘’‚]/gu;
|
|
552
|
+
const FIXED_SPACES = /[ ]/gu;
|
|
553
|
+
/** Normalise one marker's inner text: typographic quotes become straight
|
|
554
|
+
* quotes, fixed-width spaces become ordinary ones. */
|
|
555
|
+
const normalizeMarkerInner = (inner) => inner.replace(SMART_DOUBLE_QUOTES, "\"").replace(SMART_SINGLE_QUOTES, "'").replace(FIXED_SPACES, " ");
|
|
556
|
+
/**
|
|
557
|
+
* What an output marker holds between its braces: anything but a brace or a
|
|
558
|
+
* double quote, plus whole double-quoted arguments, whose braces are content
|
|
559
|
+
* rather than the marker's own delimiters — `pattern("^[0-9]{5}$")` is a regex,
|
|
560
|
+
* not a nested marker. An apostrophe stays an ordinary character, so
|
|
561
|
+
* `label("Owner's")` reads as it always did.
|
|
562
|
+
*
|
|
563
|
+
* The alternatives cannot overlap (the plain one excludes every character the
|
|
564
|
+
* quoted one may start with) and a quoted run's end is forced by its body, so
|
|
565
|
+
* the scan stays linear on adversarial input: a `{{` with no closing `}}`
|
|
566
|
+
* costs one failed pass, not a polynomial retry.
|
|
567
|
+
*/
|
|
568
|
+
const MARKER_OUTPUT_BODY = String.raw`(?:[^{}"“”„«»]|["“„«][^"”»]*["”»])*`;
|
|
569
|
+
/** What a tag holds between `{%` and `%}`: anything but a brace, and a `%`
|
|
570
|
+
* only where it does not close the tag. */
|
|
571
|
+
const MARKER_STATEMENT_BODY = String.raw`(?:[^{}%]|%(?!\}))*`;
|
|
572
|
+
/**
|
|
573
|
+
* Any marker span: an output `{{...}}` (group `output`) or a tag `{%...%}`
|
|
574
|
+
* (group `statement`, with the docxtpl placement in group `prefix`).
|
|
575
|
+
*/
|
|
576
|
+
const markerPattern = () => new RegExp(String.raw`\{\{(?<output>${MARKER_OUTPUT_BODY})\}\}|\{%(?:(?<prefix>tr|p)(?=\s))?(?<statement>${MARKER_STATEMENT_BODY})%\}`, "gu");
|
|
577
|
+
/** Inline value marker, including its filter chain; group `name` is the path
|
|
578
|
+
* the value comes from and group `filters` the untouched chain. */
|
|
579
|
+
const placeholderPattern = () => new RegExp(String.raw`\{\{\s*(?<name>[\p{L}\p{N}_.-]+)\s*(?<filters>\|${MARKER_OUTPUT_BODY})?\}\}`, "gu");
|
|
580
|
+
/** Any character that opens a quoted function argument. */
|
|
581
|
+
const OPEN_QUOTE = String.raw`["'“„«‘‚]`;
|
|
582
|
+
/** Any character that closes one. */
|
|
583
|
+
const CLOSE_QUOTE = String.raw`["'”»’]`;
|
|
584
|
+
/** Everything a quoted argument may hold: no quote of either shape. */
|
|
585
|
+
const QUOTED_BODY = String.raw`[^"'“”„«»‘’‚]*`;
|
|
586
|
+
/** `{{ clause("Name") }}` / `{{ clause("Name", "v3") }}`. */
|
|
587
|
+
const clauseSlotPattern = () => new RegExp(String.raw`\{\{\s*clause\(\s*${OPEN_QUOTE}(?<name>${QUOTED_BODY})${CLOSE_QUOTE}\s*(?:,\s*${OPEN_QUOTE}(?<modifier>${QUOTED_BODY})${CLOSE_QUOTE}\s*)?\)\s*\}\}`, "gu");
|
|
588
|
+
const numberingPattern = (fn) => new RegExp(String.raw`\{\{\s*${fn}\(\s*${OPEN_QUOTE}(?<key>[\p{L}\p{N}_.-]+)${CLOSE_QUOTE}\s*\)\s*\}\}`, "gu");
|
|
589
|
+
/** `{{ num("key") }}` — the `key` group. */
|
|
590
|
+
const numPattern = () => numberingPattern("num");
|
|
591
|
+
/** `{{ ref("key") }}` — the `key` group. */
|
|
592
|
+
const refPattern = () => numberingPattern("ref");
|
|
593
|
+
/** `{{ loop.index }}` and its siblings — the `property` group. */
|
|
594
|
+
const loopPattern = () => /\{\{\s*loop\.(?<property>index0|index|first|last|length)\s*\}\}/gu;
|
|
428
595
|
/** Cheap presence test for any numbering marker (no capture). */
|
|
429
|
-
const hasNumberingPattern = () => /\{\{\s
|
|
430
|
-
/** Cheap presence test for any
|
|
431
|
-
const hasBlockDirectivePattern = () => /\{
|
|
432
|
-
/** A whole line that is a single block
|
|
433
|
-
const blockDirectiveLinePattern = () => /^\s*\{
|
|
596
|
+
const hasNumberingPattern = () => /\{\{\s*(?:num|ref)\s*\(/u;
|
|
597
|
+
/** Cheap presence test for any tag (no capture). */
|
|
598
|
+
const hasBlockDirectivePattern = () => /\{%/u;
|
|
599
|
+
/** A whole line that is a single block tag — `prefix`, `tag` and `expr`. */
|
|
600
|
+
const blockDirectiveLinePattern = () => /^\s*\{%(?:(?<prefix>tr|p)(?=\s))?\s*(?<tag>if|elif|else|endif|for|endfor)\b(?<expr>(?:[^{}%]|%(?!\}))*)%\}\s*$/u;
|
|
434
601
|
const FIELD_PATH_RE = /^[\p{L}\p{N}_.-]+$/u;
|
|
602
|
+
const IDENTIFIER_RE = /^[\p{L}_][\p{L}\p{N}_-]*$/u;
|
|
435
603
|
const UNSAFE_FIELD_PATH_SEGMENTS = /* @__PURE__ */ new Set([
|
|
436
604
|
"__proto__",
|
|
437
605
|
"constructor",
|
|
@@ -443,130 +611,483 @@ const isFieldPath = (value) => FIELD_PATH_RE.test(value);
|
|
|
443
611
|
/** Whether `value` is safe to use as a dotted object path. */
|
|
444
612
|
const isSafeFieldPath = (value) => isFieldPath(value) && value.split(".").every((segment) => !UNSAFE_FIELD_PATH_SEGMENTS.has(segment));
|
|
445
613
|
const CLAUSE_SLOT_NAME_RE = /^[\p{L}\p{N}_.-]+$/u;
|
|
446
|
-
/** Whether `value` is a valid name for authoring a `
|
|
447
|
-
* the intersection of the clause-slot parse grammar and the fill pipeline's
|
|
448
|
-
* placeholder charset, so every accepted name round-trips through fill. */
|
|
614
|
+
/** Whether `value` is a valid name for authoring a `clause("NAME")` slot. */
|
|
449
615
|
const isClauseSlotName = (value) => CLAUSE_SLOT_NAME_RE.test(value);
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
616
|
+
const isLoopProperty = (value) => LOOP_PROPERTIES.some((property) => property === value);
|
|
617
|
+
const NUMBER_LITERAL_RE = /^-?\d+(?:\.\d+)?$/u;
|
|
618
|
+
const literalFromBareToken = (raw) => {
|
|
619
|
+
if (raw === "true") return true;
|
|
620
|
+
if (raw === "false") return false;
|
|
621
|
+
if (NUMBER_LITERAL_RE.test(raw)) return Number(raw);
|
|
622
|
+
return null;
|
|
623
|
+
};
|
|
624
|
+
/**
|
|
625
|
+
* Scan a `"..."` / `'...'` literal starting at the opening quote, honouring
|
|
626
|
+
* `\`-escapes. One linear pass; an unterminated literal closes at end of
|
|
627
|
+
* input, matching the degrade-gracefully policy of the condition parser.
|
|
628
|
+
*/
|
|
629
|
+
const scanQuoted = (text, start) => {
|
|
630
|
+
const quote = text[start];
|
|
631
|
+
let i = start + 1;
|
|
632
|
+
let value = "";
|
|
633
|
+
while (i < text.length) {
|
|
634
|
+
const ch = text[i];
|
|
635
|
+
if (ch === quote) return {
|
|
636
|
+
value,
|
|
637
|
+
end: i + 1
|
|
638
|
+
};
|
|
639
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
640
|
+
value += text[i + 1];
|
|
641
|
+
i += 2;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
value += ch;
|
|
645
|
+
i += 1;
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
value,
|
|
649
|
+
end: text.length
|
|
650
|
+
};
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Scan a parenthesised argument list starting at `(`. Arguments are string,
|
|
654
|
+
* number or boolean literals, optionally named (`adapt=true`). Anything else
|
|
655
|
+
* (an identifier, an operator, a nested call) fails the scan, which is what
|
|
656
|
+
* makes `min(a + 1)` a rejected expression rather than a silently odd filter.
|
|
657
|
+
*/
|
|
658
|
+
const scanArguments = (text, start) => {
|
|
659
|
+
const args = [];
|
|
660
|
+
let i = start + 1;
|
|
661
|
+
let expectValue = true;
|
|
662
|
+
let pendingName;
|
|
663
|
+
while (i < text.length) {
|
|
664
|
+
const ch = text[i];
|
|
665
|
+
if (ch === void 0) break;
|
|
666
|
+
if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
|
|
667
|
+
i += 1;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (ch === ")") {
|
|
671
|
+
if (pendingName !== void 0) return null;
|
|
672
|
+
if (!expectValue || args.length === 0) return {
|
|
673
|
+
args,
|
|
674
|
+
end: i + 1
|
|
675
|
+
};
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
if (ch === ",") {
|
|
679
|
+
if (expectValue) return null;
|
|
680
|
+
expectValue = true;
|
|
681
|
+
i += 1;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (!expectValue) return null;
|
|
685
|
+
if (ch === "\"" || ch === "'") {
|
|
686
|
+
const { value, end } = scanQuoted(text, i);
|
|
687
|
+
args.push(pendingName === void 0 ? {
|
|
688
|
+
kind: "positional",
|
|
689
|
+
value
|
|
690
|
+
} : {
|
|
691
|
+
kind: "keyword",
|
|
692
|
+
name: pendingName,
|
|
693
|
+
value
|
|
694
|
+
});
|
|
695
|
+
pendingName = void 0;
|
|
696
|
+
expectValue = false;
|
|
697
|
+
i = end;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
let j = i;
|
|
701
|
+
while (j < text.length && /[\p{L}\p{N}_.-]/u.test(text[j] ?? "")) j += 1;
|
|
702
|
+
if (j === i) return null;
|
|
703
|
+
const raw = text.slice(i, j);
|
|
704
|
+
if (text[j] === "=" && text[j + 1] !== "=") {
|
|
705
|
+
if (pendingName !== void 0 || !IDENTIFIER_RE.test(raw)) return null;
|
|
706
|
+
pendingName = raw;
|
|
707
|
+
i = j + 1;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const value = literalFromBareToken(raw);
|
|
711
|
+
if (value === null) return null;
|
|
712
|
+
args.push(pendingName === void 0 ? {
|
|
713
|
+
kind: "positional",
|
|
714
|
+
value
|
|
715
|
+
} : {
|
|
716
|
+
kind: "keyword",
|
|
717
|
+
name: pendingName,
|
|
718
|
+
value
|
|
719
|
+
});
|
|
720
|
+
pendingName = void 0;
|
|
721
|
+
expectValue = false;
|
|
722
|
+
i = j;
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
};
|
|
726
|
+
/**
|
|
727
|
+
* Parse the `| name | name(args)` tail of an output marker. Returns the named
|
|
728
|
+
* unknown filter instead of a chain when one step is not in the catalogue, so
|
|
729
|
+
* the rejection can quote it.
|
|
730
|
+
*/
|
|
731
|
+
const scanFilterChain = (tail) => {
|
|
732
|
+
const filters = [];
|
|
733
|
+
let i = 0;
|
|
734
|
+
while (i < tail.length) {
|
|
735
|
+
while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
|
|
736
|
+
if (i >= tail.length) break;
|
|
737
|
+
if (tail[i] !== "|") return null;
|
|
738
|
+
i += 1;
|
|
739
|
+
while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
|
|
740
|
+
let j = i;
|
|
741
|
+
while (j < tail.length && /[\p{L}\p{N}_]/u.test(tail[j] ?? "")) j += 1;
|
|
742
|
+
const name = tail.slice(i, j);
|
|
743
|
+
if (name === "") return null;
|
|
744
|
+
if (!isFilterName(name)) return { unknownFilter: name };
|
|
745
|
+
i = j;
|
|
746
|
+
while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
|
|
747
|
+
if (tail[i] === "(") {
|
|
748
|
+
const scanned = scanArguments(tail, i);
|
|
749
|
+
if (scanned === null) return null;
|
|
750
|
+
filters.push({
|
|
751
|
+
name,
|
|
752
|
+
args: scanned.args
|
|
753
|
+
});
|
|
754
|
+
i = scanned.end;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
filters.push({
|
|
758
|
+
name,
|
|
759
|
+
args: []
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
return { filters };
|
|
763
|
+
};
|
|
764
|
+
const PREFIX_BY_TOKEN = {
|
|
765
|
+
p: "paragraph",
|
|
766
|
+
tr: "row"
|
|
767
|
+
};
|
|
768
|
+
const readPrefix = (token) => token === "p" || token === "tr" ? PREFIX_BY_TOKEN[token] : "none";
|
|
769
|
+
const TAG_RE = /^(?<tag>[\p{L}_][\p{L}\p{N}_]*)\b(?<rest>[\s\S]*)$/u;
|
|
770
|
+
const FOR_RE = /^(?<alias>[\p{L}_][\p{L}\p{N}_-]*)\s+in\s+(?<path>[\p{L}\p{N}_.-]+)\s*(?<filters>\|[\s\S]*)?$/u;
|
|
771
|
+
const CALL_RE = /^(?<name>[\p{L}_][\p{L}\p{N}_]*)\s*\(/u;
|
|
772
|
+
/** Classify a `{% ... %}` tag. */
|
|
773
|
+
const classifyStatement = (inner) => {
|
|
774
|
+
const match = TAG_RE.exec(inner);
|
|
775
|
+
if (!match) return null;
|
|
776
|
+
const tag = match.groups?.["tag"] ?? "";
|
|
777
|
+
const rest = (match.groups?.["rest"] ?? "").trim();
|
|
778
|
+
switch (tag) {
|
|
779
|
+
case "if": return {
|
|
467
780
|
kind: "if",
|
|
468
|
-
expr
|
|
781
|
+
expr: rest
|
|
469
782
|
};
|
|
470
|
-
|
|
471
|
-
kind: "
|
|
472
|
-
expr
|
|
783
|
+
case "elif": return {
|
|
784
|
+
kind: "elif",
|
|
785
|
+
expr: rest
|
|
473
786
|
};
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
787
|
+
case "else": return rest === "" ? { kind: "else" } : null;
|
|
788
|
+
case "endif": return rest === "" ? { kind: "endif" } : null;
|
|
789
|
+
case "endfor": return rest === "" ? { kind: "endfor" } : null;
|
|
790
|
+
case "for": {
|
|
791
|
+
const loop = FOR_RE.exec(rest);
|
|
792
|
+
if (!loop) return null;
|
|
793
|
+
const tail = loop.groups?.["filters"];
|
|
794
|
+
const chain = tail === void 0 ? { filters: [] } : scanFilterChain(tail);
|
|
795
|
+
if (chain === null || "unknownFilter" in chain) return null;
|
|
796
|
+
return {
|
|
797
|
+
kind: "for",
|
|
798
|
+
alias: loop.groups?.["alias"] ?? "",
|
|
799
|
+
path: loop.groups?.["path"] ?? "",
|
|
800
|
+
filters: chain.filters
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
default: return null;
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
/** Classify a `{{ ... }}` output marker. */
|
|
807
|
+
const classifyOutput = (inner) => {
|
|
808
|
+
const call = CALL_RE.exec(inner);
|
|
809
|
+
if (call) {
|
|
810
|
+
const name = call.groups?.["name"] ?? "";
|
|
811
|
+
if (!isFunctionName(name)) return null;
|
|
812
|
+
const scanned = scanArguments(inner, inner.indexOf("("));
|
|
813
|
+
if (scanned === null) return null;
|
|
814
|
+
if (inner.slice(scanned.end).trim() !== "") return null;
|
|
815
|
+
const positional = scanned.args.filter((arg) => arg.kind === "positional" && typeof arg.value === "string");
|
|
816
|
+
if (positional.length !== scanned.args.length) return null;
|
|
817
|
+
const [first, second] = positional.map((arg) => String(arg.value));
|
|
818
|
+
if (first === void 0) return null;
|
|
819
|
+
if (name === "clause") return scanned.args.length <= 2 ? {
|
|
820
|
+
kind: "clause",
|
|
821
|
+
name: first,
|
|
822
|
+
version: second
|
|
823
|
+
} : null;
|
|
824
|
+
if (scanned.args.length !== 1 || !isFieldPath(first)) return null;
|
|
825
|
+
return name === "num" ? {
|
|
826
|
+
kind: "num",
|
|
827
|
+
key: first
|
|
828
|
+
} : {
|
|
829
|
+
kind: "ref",
|
|
830
|
+
key: first
|
|
477
831
|
};
|
|
478
|
-
if (token === "#else") return { kind: "else" };
|
|
479
|
-
if (token === "/if") return { kind: "endif" };
|
|
480
|
-
if (token === "/each") return { kind: "endeach" };
|
|
481
832
|
}
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
if (
|
|
495
|
-
kind: "
|
|
496
|
-
|
|
833
|
+
const pipe = inner.indexOf("|");
|
|
834
|
+
const head = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
|
|
835
|
+
const tail = pipe === -1 ? "" : inner.slice(pipe);
|
|
836
|
+
const loopMatch = /^loop\.(?<property>[\p{L}\p{N}_]+)$/u.exec(head);
|
|
837
|
+
if (loopMatch) {
|
|
838
|
+
const property = loopMatch.groups?.["property"] ?? "";
|
|
839
|
+
return isLoopProperty(property) && tail === "" ? {
|
|
840
|
+
kind: "loop",
|
|
841
|
+
property
|
|
842
|
+
} : null;
|
|
843
|
+
}
|
|
844
|
+
if (!FIELD_PATH_RE.test(head)) return null;
|
|
845
|
+
if (tail === "") return {
|
|
846
|
+
kind: "placeholder",
|
|
847
|
+
expr: head,
|
|
848
|
+
filters: []
|
|
497
849
|
};
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
|
|
850
|
+
const chain = scanFilterChain(tail);
|
|
851
|
+
if (chain === null || "unknownFilter" in chain) return null;
|
|
852
|
+
return {
|
|
501
853
|
kind: "placeholder",
|
|
502
|
-
expr:
|
|
854
|
+
expr: head,
|
|
855
|
+
filters: chain.filters
|
|
503
856
|
};
|
|
504
|
-
return null;
|
|
505
857
|
};
|
|
506
858
|
/**
|
|
507
|
-
*
|
|
508
|
-
*
|
|
859
|
+
* Classify the inner text of one marker. Returns `null` when the text is not a
|
|
860
|
+
* recognized directive (so a recognizer can leave stray braces alone).
|
|
509
861
|
*/
|
|
510
|
-
const
|
|
862
|
+
const classifyMarker = (innerRaw, form = "output") => {
|
|
863
|
+
const inner = normalizeMarkerInner(innerRaw).trim();
|
|
864
|
+
return form === "statement" ? classifyStatement(inner) : classifyOutput(inner);
|
|
865
|
+
};
|
|
866
|
+
/** Every marker-shaped span in `text`, in document order, before classification. */
|
|
867
|
+
const scanSpans = (text) => {
|
|
511
868
|
const re = markerPattern();
|
|
512
|
-
const
|
|
869
|
+
const spans = [];
|
|
513
870
|
let match = re.exec(text);
|
|
514
871
|
while (match !== null) {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
|
|
872
|
+
const output = match.groups?.["output"];
|
|
873
|
+
const statement = match.groups?.["statement"];
|
|
874
|
+
const raw = match[0];
|
|
875
|
+
const form = output === void 0 ? "statement" : "output";
|
|
876
|
+
spans.push({
|
|
518
877
|
start: match.index,
|
|
519
|
-
end: match.index +
|
|
520
|
-
raw
|
|
521
|
-
inner,
|
|
522
|
-
|
|
878
|
+
end: match.index + raw.length,
|
|
879
|
+
raw,
|
|
880
|
+
inner: normalizeMarkerInner(output ?? statement ?? "").trim(),
|
|
881
|
+
form,
|
|
882
|
+
prefix: readPrefix(match.groups?.["prefix"])
|
|
523
883
|
});
|
|
524
884
|
match = re.exec(text);
|
|
525
885
|
}
|
|
886
|
+
return spans;
|
|
887
|
+
};
|
|
888
|
+
/**
|
|
889
|
+
* Find every recognized marker in `text`, in document order, with offsets and
|
|
890
|
+
* classified metadata. Unrecognized spans are skipped.
|
|
891
|
+
*/
|
|
892
|
+
const scanMarkers = (text) => {
|
|
893
|
+
const out = [];
|
|
894
|
+
for (const span of scanSpans(text)) {
|
|
895
|
+
const meta = classifyMarker(span.inner, span.form);
|
|
896
|
+
if (meta) out.push({
|
|
897
|
+
...span,
|
|
898
|
+
meta
|
|
899
|
+
});
|
|
900
|
+
}
|
|
526
901
|
return out;
|
|
527
902
|
};
|
|
528
903
|
/**
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
|
|
904
|
+
* Rewrite every `{{ ... }}` output marker in `text` through `replace`, which
|
|
905
|
+
* receives the whole marker and its raw inner text and returns the replacement.
|
|
906
|
+
* Tags are left untouched. The single scan is the same one the classifier uses,
|
|
907
|
+
* so a caller that rewrites marker text never needs its own regex.
|
|
908
|
+
*/
|
|
909
|
+
const replaceOutputMarkers = (text, replace) => {
|
|
910
|
+
let out = "";
|
|
911
|
+
let cursor = 0;
|
|
912
|
+
for (const span of scanSpans(text)) {
|
|
913
|
+
if (span.form !== "output") continue;
|
|
914
|
+
out += text.slice(cursor, span.start) + replace(span.raw, span.inner);
|
|
915
|
+
cursor = span.end;
|
|
916
|
+
}
|
|
917
|
+
return out + text.slice(cursor);
|
|
918
|
+
};
|
|
919
|
+
/**
|
|
920
|
+
* The values-map key one marker substitutes from, or `null` when the marker is
|
|
921
|
+
* not a substitution target (a tag, or a `loop.*` token the loop expander
|
|
922
|
+
* resolves first). One function so the key discovery writes is the key the
|
|
923
|
+
* patcher reads.
|
|
924
|
+
*/
|
|
925
|
+
const substitutionKey = (meta) => {
|
|
926
|
+
switch (meta.kind) {
|
|
927
|
+
case "placeholder": return meta.expr;
|
|
928
|
+
case "clause": return meta.version === void 0 ? `${CLAUSE_KEY_PREFIX}${meta.name}` : `${CLAUSE_KEY_PREFIX}${meta.name}:${meta.version}`;
|
|
929
|
+
case "num":
|
|
930
|
+
case "ref":
|
|
931
|
+
case "loop":
|
|
932
|
+
case "if":
|
|
933
|
+
case "elif":
|
|
934
|
+
case "else":
|
|
935
|
+
case "endif":
|
|
936
|
+
case "for":
|
|
937
|
+
case "endfor": return null;
|
|
938
|
+
default: return assertNever(meta);
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
/** Namespace of the synthetic clause-slot key, kept out of the field-path
|
|
942
|
+
* charset so it can never collide with an author's path. */
|
|
943
|
+
const CLAUSE_KEY_PREFIX = "@clause:";
|
|
944
|
+
/** The values-map key for one clause slot. */
|
|
945
|
+
const clauseSlotKey = (name, version) => version === void 0 ? `${CLAUSE_KEY_PREFIX}${name}` : `${CLAUSE_KEY_PREFIX}${name}:${version}`;
|
|
946
|
+
/**
|
|
947
|
+
* Find every marker-shaped span whose inner text is NOT a recognized
|
|
948
|
+
* directive — near-misses an author clearly meant as markers but that every
|
|
949
|
+
* recognizer skips, so they print literally at fill time. This is the exact
|
|
534
950
|
* complement of {@link scanMarkers}: a span is in one list or the other.
|
|
535
951
|
*/
|
|
536
952
|
const scanInvalidMarkers = (text) => {
|
|
537
|
-
const re = markerPattern();
|
|
538
953
|
const out = [];
|
|
539
|
-
|
|
540
|
-
while (match !== null) {
|
|
541
|
-
const inner = (match[1] ?? "").trim();
|
|
542
|
-
if (classifyMarker(inner) === null) out.push({
|
|
543
|
-
start: match.index,
|
|
544
|
-
end: match.index + match[0].length,
|
|
545
|
-
raw: match[0],
|
|
546
|
-
inner
|
|
547
|
-
});
|
|
548
|
-
match = re.exec(text);
|
|
549
|
-
}
|
|
954
|
+
for (const { prefix: _prefix, ...span } of scanSpans(text)) if (classifyMarker(span.inner, span.form) === null) out.push(span);
|
|
550
955
|
return out;
|
|
551
956
|
};
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
const
|
|
957
|
+
const LEGACY_BLOCK_RE = /^(?<token>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[\s\S]*)$/u;
|
|
958
|
+
const LEGACY_CLAUSE_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
|
|
959
|
+
const LEGACY_NUM_RE = /^@(?<fn>num|ref):(?<key>[\p{L}\p{N}_.-]+)$/u;
|
|
960
|
+
/** Singular of a plain-word path segment, for a generated loop alias. */
|
|
961
|
+
const singularize = (segment) => {
|
|
962
|
+
if (segment.endsWith("ies") && segment.length > 3) return `${segment.slice(0, -3)}y`;
|
|
963
|
+
if (/(?:s|ss|sh|ch|x|z)es$/u.test(segment)) return segment.slice(0, -2);
|
|
964
|
+
if (/[^s]s$/u.test(segment)) return segment.slice(0, -1);
|
|
965
|
+
return segment;
|
|
966
|
+
};
|
|
967
|
+
/** The loop alias the codemod (and the legacy-marker message) generates for a
|
|
968
|
+
* `{{#each path}}`: the singular of the last plain-word segment, else `item`. */
|
|
969
|
+
const legacyLoopAlias = (path) => {
|
|
970
|
+
const segment = path.split(".").at(-1) ?? "";
|
|
971
|
+
if (!IDENTIFIER_RE.test(segment)) return "item";
|
|
972
|
+
const alias = singularize(segment);
|
|
973
|
+
return alias === "" || alias === segment ? "item" : alias;
|
|
974
|
+
};
|
|
975
|
+
/** Translate an old-dialect condition expression to the Jinja surface:
|
|
976
|
+
* `!a` becomes `not a`, `a contains "x"` becomes `"x" in a`. */
|
|
977
|
+
const translateLegacyExpression = (expr) => expr.replace(/(?<![\p{L}\p{N}_.-])(?<path>[\p{L}\p{N}_.-]+)\s+contains\s+(?<value>"[^"]*"|[\p{L}\p{N}_.-]+)/gu, (_m, path, value) => `${value.startsWith("\"") ? value : `"${value}"`} in ${path}`).replace(/!(?!=)\s*/gu, "not ");
|
|
978
|
+
/**
|
|
979
|
+
* The Jinja marker that replaces one old-dialect marker's inner text, or
|
|
980
|
+
* `null` when the text is not old-dialect. Shared by the rejection message and
|
|
981
|
+
* the codemod, so the fix an author is told to make is the fix the script
|
|
982
|
+
* would have made.
|
|
983
|
+
*/
|
|
984
|
+
const legacyMarkerReplacement = (innerRaw) => {
|
|
985
|
+
const inner = normalizeMarkerInner(innerRaw).trim();
|
|
986
|
+
const block = LEGACY_BLOCK_RE.exec(inner);
|
|
987
|
+
if (block) {
|
|
988
|
+
const token = block.groups?.["token"] ?? "";
|
|
989
|
+
const expr = translateLegacyExpression((block.groups?.["expr"] ?? "").trim());
|
|
990
|
+
switch (token) {
|
|
991
|
+
case "#if": return `{% if ${expr} %}`;
|
|
992
|
+
case "#elseif": return `{% elif ${expr} %}`;
|
|
993
|
+
case "#else": return "{% else %}";
|
|
994
|
+
case "/if": return "{% endif %}";
|
|
995
|
+
case "#each": return `{% for ${legacyLoopAlias(expr)} in ${expr} %}`;
|
|
996
|
+
case "/each": return "{% endfor %}";
|
|
997
|
+
default: return null;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const clause = LEGACY_CLAUSE_RE.exec(inner);
|
|
1001
|
+
if (clause) {
|
|
1002
|
+
const name = clause.groups?.["name"] ?? "";
|
|
1003
|
+
const version = clause.groups?.["version"];
|
|
1004
|
+
return version === void 0 ? `{{ clause("${name}") }}` : `{{ clause("${name}", "${version}") }}`;
|
|
1005
|
+
}
|
|
1006
|
+
const numbering = LEGACY_NUM_RE.exec(inner);
|
|
1007
|
+
if (numbering) return `{{ ${numbering.groups?.["fn"] ?? ""}("${numbering.groups?.["key"] ?? ""}") }}`;
|
|
1008
|
+
if (inner === "@index") return "{{ loop.index }}";
|
|
1009
|
+
if (inner === "@count") return "{{ loop.length }}";
|
|
1010
|
+
return null;
|
|
1011
|
+
};
|
|
1012
|
+
const MARKER_DEFECT_KINDS = [
|
|
1013
|
+
"legacy_marker",
|
|
1014
|
+
"unsupported_tag",
|
|
1015
|
+
"unknown_filter",
|
|
1016
|
+
"python_expression",
|
|
1017
|
+
"bracket_index"
|
|
1018
|
+
];
|
|
555
1019
|
/** Bracket indexing (`items[0].name`), which no directive kind admits. */
|
|
556
1020
|
const BRACKET_INDEX_RE = /[[\]]/u;
|
|
1021
|
+
/** A legacy directive sigil at the head of an output marker. */
|
|
1022
|
+
const LEGACY_SHAPE_RE = /^[#/@]/u;
|
|
557
1023
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1024
|
+
* Arithmetic, a call, a quoted string or a Python literal inside `{{ }}`. A
|
|
1025
|
+
* span that merely holds a space (`{{my field}}`) is a mistyped path, not an
|
|
1026
|
+
* expression, and stays undiagnosed so the check reports it as an unreadable
|
|
1027
|
+
* marker rather than guessing at Python.
|
|
1028
|
+
*/
|
|
1029
|
+
const PYTHON_SHAPE_RE = /["'()+*/%]|\bNone\b|\bTrue\b|\bFalse\b/u;
|
|
1030
|
+
/**
|
|
1031
|
+
* Name the authoring mistake behind one marker's inner text, or `null` when
|
|
1032
|
+
* the text is a recognized directive ({@link classifyMarker} accepts it) or an
|
|
1033
|
+
* unrecognizable span with no specific diagnosis (`{{my field}}`).
|
|
1034
|
+
*/
|
|
1035
|
+
const classifyMarkerDefect = (innerRaw, form = "output") => {
|
|
1036
|
+
const inner = normalizeMarkerInner(innerRaw).trim();
|
|
1037
|
+
if (classifyMarker(inner, form) !== null) return null;
|
|
1038
|
+
if (form === "statement") {
|
|
1039
|
+
const tag = TAG_RE.exec(inner)?.groups?.["tag"];
|
|
1040
|
+
if (tag === void 0) return null;
|
|
1041
|
+
const legacy = legacyMarkerReplacement(inner);
|
|
1042
|
+
if (legacy !== null) return {
|
|
1043
|
+
kind: "legacy_marker",
|
|
1044
|
+
construct: inner,
|
|
1045
|
+
replacement: legacy
|
|
1046
|
+
};
|
|
1047
|
+
return {
|
|
1048
|
+
kind: "unsupported_tag",
|
|
1049
|
+
construct: tag
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const legacy = legacyMarkerReplacement(inner);
|
|
1053
|
+
if (legacy !== null) return {
|
|
1054
|
+
kind: "legacy_marker",
|
|
1055
|
+
construct: inner,
|
|
1056
|
+
replacement: legacy
|
|
1057
|
+
};
|
|
1058
|
+
if (LEGACY_SHAPE_RE.test(inner)) return {
|
|
1059
|
+
kind: "legacy_marker",
|
|
1060
|
+
construct: inner
|
|
1061
|
+
};
|
|
1062
|
+
if (BRACKET_INDEX_RE.test(inner)) return {
|
|
1063
|
+
kind: "bracket_index",
|
|
1064
|
+
construct: inner
|
|
1065
|
+
};
|
|
1066
|
+
const pipe = inner.indexOf("|");
|
|
1067
|
+
if (pipe !== -1) {
|
|
1068
|
+
const chain = scanFilterChain(inner.slice(pipe));
|
|
1069
|
+
if (chain !== null && "unknownFilter" in chain) return {
|
|
1070
|
+
kind: "unknown_filter",
|
|
1071
|
+
construct: chain.unknownFilter
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
const call = CALL_RE.exec(inner);
|
|
1075
|
+
if (call) {
|
|
1076
|
+
const name = call.groups?.["name"] ?? "";
|
|
1077
|
+
if (!isFunctionName(name)) return {
|
|
1078
|
+
kind: "python_expression",
|
|
1079
|
+
construct: `${name}(...)`
|
|
1080
|
+
};
|
|
1081
|
+
return {
|
|
1082
|
+
kind: "python_expression",
|
|
1083
|
+
construct: inner
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const head = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
|
|
1087
|
+
if (head !== "" && !FIELD_PATH_RE.test(head) && PYTHON_SHAPE_RE.test(head)) return {
|
|
1088
|
+
kind: "python_expression",
|
|
1089
|
+
construct: head
|
|
1090
|
+
};
|
|
570
1091
|
return null;
|
|
571
1092
|
};
|
|
572
1093
|
/** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
|
|
@@ -650,7 +1171,7 @@ const formatDate = (value, dateFormat) => {
|
|
|
650
1171
|
* value are left as-is (a visible authoring artifact, not a render error), so
|
|
651
1172
|
* a partially filled composite previews exactly as the fill engine renders it.
|
|
652
1173
|
*/
|
|
653
|
-
const renderComposite = (parts, format, partValues) => format
|
|
1174
|
+
const renderComposite = (parts, format, partValues) => replaceOutputMarkers(format, (raw, inner) => {
|
|
654
1175
|
const key = inner.trim();
|
|
655
1176
|
if (!parts.some((part) => part.key === key)) return raw;
|
|
656
1177
|
return partValues[key] ?? raw;
|
|
@@ -732,8 +1253,10 @@ const serializePredicate = (node) => {
|
|
|
732
1253
|
const operand = serializeOperand(node.operand);
|
|
733
1254
|
if (node.op === "contains") {
|
|
734
1255
|
const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
|
|
735
|
-
return `${
|
|
1256
|
+
return `${serializeLiteral(value)} in ${operand}`;
|
|
736
1257
|
}
|
|
1258
|
+
if (node.op === "is_not_empty") return `${operand} is defined`;
|
|
1259
|
+
if (node.op === "is_empty") return `${operand} is not defined`;
|
|
737
1260
|
return operand;
|
|
738
1261
|
};
|
|
739
1262
|
const serializeGroup = (group, top) => {
|
|
@@ -744,7 +1267,7 @@ const serializeGroup = (group, top) => {
|
|
|
744
1267
|
}
|
|
745
1268
|
if (parts.length === 0) return "";
|
|
746
1269
|
const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
|
|
747
|
-
if (group.negated) return
|
|
1270
|
+
if (group.negated) return `not (${body})`;
|
|
748
1271
|
if (parts.length === 1) return body;
|
|
749
1272
|
return top ? body : `(${body})`;
|
|
750
1273
|
};
|
|
@@ -770,7 +1293,7 @@ const serializeCondition = (node) => serializeNode(node, true);
|
|
|
770
1293
|
* an author instead types the opener in front of the first cell's text and the
|
|
771
1294
|
* closer behind the last cell's text:
|
|
772
1295
|
*
|
|
773
|
-
* | {
|
|
1296
|
+
* | {% for d in deliverables %}{{ d.item }} | {{ d.fee }}{% endfor %} |
|
|
774
1297
|
*
|
|
775
1298
|
* Both placements mean the same thing — the `w:tr` is the unit — so this
|
|
776
1299
|
* module recognizes the row form purely from text, and the consumer rewrites
|
|
@@ -781,18 +1304,18 @@ const serializeCondition = (node) => serializeNode(node, true);
|
|
|
781
1304
|
* and neither can drift from the other's idea of what a row block is.
|
|
782
1305
|
*/
|
|
783
1306
|
const CLOSER_OF = {
|
|
784
|
-
|
|
1307
|
+
for: "endfor",
|
|
785
1308
|
if: "endif"
|
|
786
1309
|
};
|
|
787
|
-
const isOpenKind = (kind) => kind === "
|
|
1310
|
+
const isOpenKind = (kind) => kind === "for" || kind === "if";
|
|
788
1311
|
/**
|
|
789
1312
|
* Block markers in one paragraph that do not pair inside it. A pair that opens
|
|
790
|
-
* and closes within the paragraph is an inline span (`the Buyer{
|
|
791
|
-
* spouse{
|
|
1313
|
+
* and closes within the paragraph is an inline span (`the Buyer{% if x %} and
|
|
1314
|
+
* spouse{% endif %}`), which the inline engine already owns; only what is left
|
|
792
1315
|
* over can reach across cells.
|
|
793
1316
|
*
|
|
794
|
-
* A branch marker (`{
|
|
795
|
-
* `{
|
|
1317
|
+
* A branch marker (`{% elif %}`, `{% else %}`) counts as left over unless its
|
|
1318
|
+
* `{% if %}` also closes in this paragraph. Only the opener and the closer of a
|
|
796
1319
|
* row block are hoisted, so a branch marker stranded between them would stay
|
|
797
1320
|
* buried in the cell text: a false condition would drop the whole row and the
|
|
798
1321
|
* branch that should have rendered with it, and a true one would leave the
|
|
@@ -816,13 +1339,13 @@ const danglingBlockMarkers = (text) => {
|
|
|
816
1339
|
});
|
|
817
1340
|
continue;
|
|
818
1341
|
}
|
|
819
|
-
if (kind === "
|
|
1342
|
+
if (kind === "endfor" || kind === "endif") {
|
|
820
1343
|
const innermost = open.at(-1);
|
|
821
1344
|
if (innermost && CLOSER_OF[innermost.kind] === kind) open.pop();
|
|
822
1345
|
else dangling.push(marker);
|
|
823
1346
|
continue;
|
|
824
1347
|
}
|
|
825
|
-
if (kind === "
|
|
1348
|
+
if (kind === "elif" || kind === "else") {
|
|
826
1349
|
const innermost = open.at(-1);
|
|
827
1350
|
if (innermost?.kind === "if") innermost.branches.push(marker);
|
|
828
1351
|
else dangling.push(marker);
|
|
@@ -847,7 +1370,7 @@ const suffixesCell = (paragraphs, { marker, paragraphIndex }) => paragraphIndex
|
|
|
847
1370
|
* paragraph, they are a matching opener/closer pair, the closer sits in a LATER
|
|
848
1371
|
* cell than the opener, the opener prefixes its cell and the closer suffixes
|
|
849
1372
|
* its cell. Everything else — one half of a pair, two nested pairs, a stray
|
|
850
|
-
* `{
|
|
1373
|
+
* `{% else %}`, a pair wrapping the paragraphs of a single cell — is left to the
|
|
851
1374
|
* engine's existing structure errors rather than guessed at.
|
|
852
1375
|
*/
|
|
853
1376
|
const detectRowBlockPair = (cells) => {
|
|
@@ -878,7 +1401,7 @@ const detectRowBlockPair = (cells) => {
|
|
|
878
1401
|
*
|
|
879
1402
|
* Boolean conditions are evaluated through the canonical `@stll/conditions`
|
|
880
1403
|
* AST and its single evaluator, so view filters, AI-extraction gating, and
|
|
881
|
-
* template `{
|
|
1404
|
+
* template `{% if ... %}` conditionals share one set of operators and semantics
|
|
882
1405
|
* and never drift apart. This module owns the template surface: the string
|
|
883
1406
|
* parser (`./parse`), the named-condition resolver, and the no-code builder
|
|
884
1407
|
* serializer (`./condition-builder`).
|
|
@@ -892,7 +1415,7 @@ const MAX_CONDITION_DEPTH = 50;
|
|
|
892
1415
|
/**
|
|
893
1416
|
* Normalize a fill-bag value into a `ConditionValue` the shared evaluator
|
|
894
1417
|
* accepts: arrays become string arrays (multi-select fields hold their options
|
|
895
|
-
* as strings, so membership tests like `
|
|
1418
|
+
* as strings, so membership tests like `"guarantor" in parties` work
|
|
896
1419
|
* even when an element is numeric), primitives pass through, anything else is
|
|
897
1420
|
* stringified.
|
|
898
1421
|
*/
|
|
@@ -905,7 +1428,7 @@ const toConditionValue = (raw) => {
|
|
|
905
1428
|
* Build the operand resolver for one evaluation. Only `path` operands need
|
|
906
1429
|
* resolving (the evaluator handles literals). A path is first matched against
|
|
907
1430
|
* the named conditions: a hit evaluates that condition's expression — so
|
|
908
|
-
* `{
|
|
1431
|
+
* `{% if isCompany %}` and `{% if isCompany and signed %}` both work — guarded
|
|
909
1432
|
* against cycles (`resolved`) and runaway chains (`MAX_CONDITION_DEPTH`).
|
|
910
1433
|
* Otherwise the path resolves against the fill data.
|
|
911
1434
|
*/
|
|
@@ -927,12 +1450,13 @@ const makeResolver = (data, namedConditions, resolved, depth) => {
|
|
|
927
1450
|
return resolve;
|
|
928
1451
|
};
|
|
929
1452
|
/**
|
|
930
|
-
* Evaluate a template `{
|
|
1453
|
+
* Evaluate a template `{% if ... %}` condition. The expression is parsed into
|
|
931
1454
|
* the canonical `@stll/conditions` AST and evaluated by the shared evaluator.
|
|
932
1455
|
*
|
|
933
|
-
* Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`),
|
|
934
|
-
*
|
|
935
|
-
*
|
|
1456
|
+
* Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`), membership
|
|
1457
|
+
* (`"x" in path`), presence (`is defined` / `is not defined`), truthiness,
|
|
1458
|
+
* negation (`not`), logical operators (`and`, `or`), parentheses, dotted
|
|
1459
|
+
* paths, numeric underscores, and named-condition references.
|
|
936
1460
|
*
|
|
937
1461
|
* An empty or unparseable expression is `false` ("no condition" never gates a
|
|
938
1462
|
* branch true).
|
|
@@ -943,4 +1467,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
|
|
|
943
1467
|
return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
|
|
944
1468
|
};
|
|
945
1469
|
//#endregion
|
|
946
|
-
export { BLOCK_DIRECTIVE_KINDS, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, MARKER_DEFECT_KINDS, MAX_CONDITION_DEPTH, NUMERIC_FUNCTION_NAMES, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema,
|
|
1470
|
+
export { BLOCK_DIRECTIVE_KINDS, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, FILTER_NAMES, LOOP_PROPERTIES, MARKER_DEFECT_KINDS, MARKER_OUTPUT_BODY, MARKER_STATEMENT_BODY, MAX_CONDITION_DEPTH, NUMERIC_FUNCTION_NAMES, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotKey, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, legacyLoopAlias, legacyMarkerReplacement, loopPattern, markerPattern, normalizeMarkerInner, numPattern, parseCondition, placeholderPattern, refPattern, referencedConditionPaths, renderComposite, renderDeterministicFieldValue, replaceOutputMarkers, resolvePath, scanInvalidMarkers, scanMarkers, serializeCondition, substitutionKey, translateLegacyExpression };
|