@stll/template-conditions 0.3.0 → 0.5.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 +361 -93
- package/dist/index.js +958 -253
- 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,196 +419,46 @@ const parseCondition = (expression) => {
|
|
|
387
419
|
};
|
|
388
420
|
return parseOr();
|
|
389
421
|
};
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
"
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
/** Directives that occupy their own paragraph, in canonical grammar order. */
|
|
405
|
-
const BLOCK_DIRECTIVE_KINDS = [
|
|
406
|
-
"if",
|
|
407
|
-
"elseif",
|
|
408
|
-
"else",
|
|
409
|
-
"endif",
|
|
410
|
-
"each",
|
|
411
|
-
"endeach"
|
|
412
|
-
];
|
|
413
|
-
const isBlockDirectiveKind = (kind) => typeof kind === "string" && BLOCK_DIRECTIVE_KINDS.some((blockKind) => blockKind === kind);
|
|
414
|
-
/** Any `{{...}}` marker; group 1 is the inner text (untrimmed — callers trim). */
|
|
415
|
-
const markerPattern = () => /\{\{(?<inner>[^{}]*)\}\}/gu;
|
|
416
|
-
/** Inline field / clause / numbering marker (inner allows the directive sigils). */
|
|
417
|
-
const placeholderPattern = () => /\{\{\s*(?<name>[\p{L}\p{N}_.@:-]+)\s*\}\}/gu;
|
|
418
|
-
/** `{{@clause:Name}}` / `{{@clause:Name:v3}}` — `name` and `modifier` groups. */
|
|
419
|
-
const clauseSlotPattern = () => /\{\{\s*@clause:(?<name>[^:}\s]+)(?::(?<modifier>[^}\s]+))?\s*\}\}/gu;
|
|
420
|
-
/** `{{@num:Key}}` — the `key` group. */
|
|
421
|
-
const numPattern = () => /\{\{\s*@num:(?<key>[\p{L}\p{N}_.-]+)\s*\}\}/gu;
|
|
422
|
-
/** `{{@ref:Key}}` — the `key` group. */
|
|
423
|
-
const refPattern = () => /\{\{\s*@ref:(?<key>[\p{L}\p{N}_.-]+)\s*\}\}/gu;
|
|
424
|
-
/** `{{@index}}` — the 1-based position within the innermost enclosing loop. */
|
|
425
|
-
const indexPattern = () => /\{\{\s*@index\s*\}\}/gu;
|
|
426
|
-
/** `{{@count}}` — the item count of the innermost enclosing loop. */
|
|
427
|
-
const countPattern = () => /\{\{\s*@count\s*\}\}/gu;
|
|
428
|
-
/** Cheap presence test for any numbering marker (no capture). */
|
|
429
|
-
const hasNumberingPattern = () => /\{\{\s*@(?:num|ref):/u;
|
|
430
|
-
/** Cheap presence test for any block directive (no capture). */
|
|
431
|
-
const hasBlockDirectivePattern = () => /\{\{\s*[#/]/u;
|
|
432
|
-
/** A whole line that is a single block directive — `tag` and `expr` groups. */
|
|
433
|
-
const blockDirectiveLinePattern = () => /^\s*\{\{\s*(?<tag>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[^{}]*)\}\}\s*$/u;
|
|
434
|
-
const FIELD_PATH_RE = /^[\p{L}\p{N}_.-]+$/u;
|
|
435
|
-
const UNSAFE_FIELD_PATH_SEGMENTS = /* @__PURE__ */ new Set([
|
|
436
|
-
"__proto__",
|
|
437
|
-
"constructor",
|
|
438
|
-
"prototype"
|
|
439
|
-
]);
|
|
440
|
-
/** Whether `value` is a valid field path per the marker grammar (dotted
|
|
441
|
-
* segments of letters/digits/underscore/dash — no brackets or spaces). */
|
|
442
|
-
const isFieldPath = (value) => FIELD_PATH_RE.test(value);
|
|
443
|
-
/** Whether `value` is safe to use as a dotted object path. */
|
|
444
|
-
const isSafeFieldPath = (value) => isFieldPath(value) && value.split(".").every((segment) => !UNSAFE_FIELD_PATH_SEGMENTS.has(segment));
|
|
445
|
-
const CLAUSE_SLOT_NAME_RE = /^[\p{L}\p{N}_.-]+$/u;
|
|
446
|
-
/** Whether `value` is a valid name for authoring a `{{@clause:NAME}}` slot:
|
|
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. */
|
|
449
|
-
const isClauseSlotName = (value) => CLAUSE_SLOT_NAME_RE.test(value);
|
|
450
|
-
const CLAUSE_INNER_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
|
|
451
|
-
const NUM_INNER_RE = /^@num:(?<key>[\p{L}\p{N}_.-]+)$/u;
|
|
452
|
-
const REF_INNER_RE = /^@ref:(?<key>[\p{L}\p{N}_.-]+)$/u;
|
|
453
|
-
const INDEX_INNER_RE = /^@index$/u;
|
|
454
|
-
const COUNT_INNER_RE = /^@count$/u;
|
|
455
|
-
const BLOCK_INNER_RE = /^(?<token>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>.*)$/u;
|
|
456
|
-
/**
|
|
457
|
-
* Classify the inner text of a `{{...}}` marker. Returns `null` when the text is
|
|
458
|
-
* not a recognized directive (so a recognizer can leave stray braces alone).
|
|
459
|
-
*/
|
|
460
|
-
const classifyMarker = (innerRaw) => {
|
|
461
|
-
const inner = innerRaw.trim();
|
|
462
|
-
const block = BLOCK_INNER_RE.exec(inner);
|
|
463
|
-
if (block) {
|
|
464
|
-
const token = block.groups?.["token"] ?? "";
|
|
465
|
-
const expr = (block.groups?.["expr"] ?? "").trim();
|
|
466
|
-
if (token === "#if") return {
|
|
467
|
-
kind: "if",
|
|
468
|
-
expr
|
|
469
|
-
};
|
|
470
|
-
if (token === "#elseif") return {
|
|
471
|
-
kind: "elseif",
|
|
472
|
-
expr
|
|
473
|
-
};
|
|
474
|
-
if (token === "#each") return {
|
|
475
|
-
kind: "each",
|
|
476
|
-
expr
|
|
477
|
-
};
|
|
478
|
-
if (token === "#else") return { kind: "else" };
|
|
479
|
-
if (token === "/if") return { kind: "endif" };
|
|
480
|
-
if (token === "/each") return { kind: "endeach" };
|
|
481
|
-
}
|
|
482
|
-
const clause = CLAUSE_INNER_RE.exec(inner);
|
|
483
|
-
if (clause) return {
|
|
484
|
-
kind: "clause",
|
|
485
|
-
name: clause.groups?.["name"] ?? "",
|
|
486
|
-
version: clause.groups?.["version"]
|
|
487
|
-
};
|
|
488
|
-
const num = NUM_INNER_RE.exec(inner);
|
|
489
|
-
if (num) return {
|
|
490
|
-
kind: "num",
|
|
491
|
-
key: num.groups?.["key"] ?? ""
|
|
492
|
-
};
|
|
493
|
-
const ref = REF_INNER_RE.exec(inner);
|
|
494
|
-
if (ref) return {
|
|
495
|
-
kind: "ref",
|
|
496
|
-
key: ref.groups?.["key"] ?? ""
|
|
497
|
-
};
|
|
498
|
-
if (INDEX_INNER_RE.test(inner)) return { kind: "index" };
|
|
499
|
-
if (COUNT_INNER_RE.test(inner)) return { kind: "count" };
|
|
500
|
-
if (FIELD_PATH_RE.test(inner)) return {
|
|
501
|
-
kind: "placeholder",
|
|
502
|
-
expr: inner
|
|
503
|
-
};
|
|
504
|
-
return null;
|
|
505
|
-
};
|
|
506
|
-
/**
|
|
507
|
-
* Find every recognized `{{...}}` marker in `text`, in document order, with
|
|
508
|
-
* offsets and classified metadata. Unrecognized `{{...}}` spans are skipped.
|
|
509
|
-
*/
|
|
510
|
-
const scanMarkers = (text) => {
|
|
511
|
-
const re = markerPattern();
|
|
512
|
-
const out = [];
|
|
513
|
-
let match = re.exec(text);
|
|
514
|
-
while (match !== null) {
|
|
515
|
-
const inner = (match[1] ?? "").trim();
|
|
516
|
-
const meta = classifyMarker(inner);
|
|
517
|
-
if (meta) out.push({
|
|
518
|
-
start: match.index,
|
|
519
|
-
end: match.index + match[0].length,
|
|
520
|
-
raw: match[0],
|
|
521
|
-
inner,
|
|
522
|
-
meta
|
|
523
|
-
});
|
|
524
|
-
match = re.exec(text);
|
|
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;
|
|
525
436
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
* near-misses an author clearly meant as markers but that every recognizer
|
|
531
|
-
* skips, so they print literally at fill time (e.g. `{{my field}}` with a
|
|
532
|
-
* space, `{{@clause:}}` with no name). Offsets and raw text are shaped like
|
|
533
|
-
* {@link scanMarkers} results, minus the classified `meta`. This is the exact
|
|
534
|
-
* complement of {@link scanMarkers}: a span is in one list or the other.
|
|
535
|
-
*/
|
|
536
|
-
const scanInvalidMarkers = (text) => {
|
|
537
|
-
const re = markerPattern();
|
|
538
|
-
const out = [];
|
|
539
|
-
let match = re.exec(text);
|
|
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);
|
|
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);
|
|
549
441
|
}
|
|
550
|
-
return
|
|
442
|
+
return paths;
|
|
551
443
|
};
|
|
552
|
-
const MARKER_DEFECT_KINDS = ["unknown_directive", "bracket_index"];
|
|
553
|
-
/** A `{{#...}}` / `{{/...}}` shape: an author reaching for a block directive. */
|
|
554
|
-
const DIRECTIVE_SHAPE_RE = /^[#/]/u;
|
|
555
|
-
/** Bracket indexing (`items[0].name`), which no directive kind admits. */
|
|
556
|
-
const BRACKET_INDEX_RE = /[[\]]/u;
|
|
557
444
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*/
|
|
565
|
-
const
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
if (DIRECTIVE_SHAPE_RE.test(inner)) return "unknown_directive";
|
|
569
|
-
if (BRACKET_INDEX_RE.test(inner)) return "bracket_index";
|
|
570
|
-
return null;
|
|
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);
|
|
571
455
|
};
|
|
572
|
-
/** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
|
|
573
|
-
const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
|
|
574
456
|
//#endregion
|
|
575
457
|
//#region src/field-values.ts
|
|
576
458
|
/**
|
|
577
459
|
* Single source of truth for the DETERMINISTIC field-value transforms a
|
|
578
|
-
* template fill applies
|
|
579
|
-
*
|
|
460
|
+
* template fill applies: formula (arithmetic over the other values) and
|
|
461
|
+
* locale-aware date rendering.
|
|
580
462
|
*
|
|
581
463
|
* Both the API fill engine (apps/api/src/handlers/docx) and the web live
|
|
582
464
|
* preview (template-studio.tsx) MUST route their rendering through
|
|
@@ -646,52 +528,20 @@ const formatDate = (value, dateFormat) => {
|
|
|
646
528
|
return getDateFormatter(dateFormat.locale, dateFormat.style).format(date);
|
|
647
529
|
};
|
|
648
530
|
/**
|
|
649
|
-
* Render a `{{key}}` format over part values. Markers whose key has no part
|
|
650
|
-
* value are left as-is (a visible authoring artifact, not a render error), so
|
|
651
|
-
* a partially filled composite previews exactly as the fill engine renders it.
|
|
652
|
-
*/
|
|
653
|
-
const renderComposite = (parts, format, partValues) => format.replace(markerPattern(), (raw, inner) => {
|
|
654
|
-
const key = inner.trim();
|
|
655
|
-
if (!parts.some((part) => part.key === key)) return raw;
|
|
656
|
-
return partValues[key] ?? raw;
|
|
657
|
-
});
|
|
658
|
-
/** A plain object of part values: a non-null, non-array object. The api
|
|
659
|
-
* composite step accepts exactly this shape (an array or primitive is not a
|
|
660
|
-
* part-values object), so the dispatcher mirrors it. */
|
|
661
|
-
const isPartValuesObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
662
|
-
/** Build the `{{key}} → value` map for a composite field from its raw object
|
|
663
|
-
* value, keeping only string part values for declared part keys. */
|
|
664
|
-
const compositePartValues = (parts, raw) => {
|
|
665
|
-
const partValues = {};
|
|
666
|
-
for (const part of parts) {
|
|
667
|
-
const value = raw[part.key];
|
|
668
|
-
if (typeof value === "string") partValues[part.key] = value;
|
|
669
|
-
}
|
|
670
|
-
return partValues;
|
|
671
|
-
};
|
|
672
|
-
/**
|
|
673
531
|
* THE deterministic field-value dispatcher: returns the string the fill engine
|
|
674
532
|
* writes for `field` given the submitted `values`, or null when the field has
|
|
675
533
|
* no deterministic transform (a scalar, lookup, or AI field the CALLER renders
|
|
676
534
|
* itself).
|
|
677
535
|
*
|
|
678
|
-
* Dispatch order mirrors the API fill pipeline (
|
|
679
|
-
* -
|
|
680
|
-
* - else formula present → {@link evaluateNumericExpression}, stringified
|
|
536
|
+
* Dispatch order mirrors the API fill pipeline (formula → date):
|
|
537
|
+
* - formula present → {@link evaluateNumericExpression}, stringified
|
|
681
538
|
* - else date (inputType "date" + dateFormat) → {@link formatDate}
|
|
682
539
|
* - else null
|
|
683
540
|
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
* scalar path handles it). Formula and date return null when the expression /
|
|
687
|
-
* value does not yield a value, so the caller leaves the field as-is.
|
|
541
|
+
* Formula and date return null when the expression or value does not yield a
|
|
542
|
+
* value, so the caller leaves the field as-is.
|
|
688
543
|
*/
|
|
689
544
|
const renderDeterministicFieldValue = (field, values) => {
|
|
690
|
-
if (field.parts !== void 0 && field.format !== void 0) {
|
|
691
|
-
const raw = resolvePath(field.path, values);
|
|
692
|
-
if (!isPartValuesObject(raw)) return null;
|
|
693
|
-
return renderComposite(field.parts, field.format, compositePartValues(field.parts, raw));
|
|
694
|
-
}
|
|
695
545
|
if (field.formula !== void 0) {
|
|
696
546
|
const result = evaluateNumericExpression(field.formula, values);
|
|
697
547
|
return result === void 0 ? null : String(result);
|
|
@@ -732,8 +582,10 @@ const serializePredicate = (node) => {
|
|
|
732
582
|
const operand = serializeOperand(node.operand);
|
|
733
583
|
if (node.op === "contains") {
|
|
734
584
|
const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
|
|
735
|
-
return `${
|
|
585
|
+
return `${serializeLiteral(value)} in ${operand}`;
|
|
736
586
|
}
|
|
587
|
+
if (node.op === "is_not_empty") return `${operand} is defined`;
|
|
588
|
+
if (node.op === "is_empty") return `${operand} is not defined`;
|
|
737
589
|
return operand;
|
|
738
590
|
};
|
|
739
591
|
const serializeGroup = (group, top) => {
|
|
@@ -744,7 +596,7 @@ const serializeGroup = (group, top) => {
|
|
|
744
596
|
}
|
|
745
597
|
if (parts.length === 0) return "";
|
|
746
598
|
const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
|
|
747
|
-
if (group.negated) return
|
|
599
|
+
if (group.negated) return `not (${body})`;
|
|
748
600
|
if (parts.length === 1) return body;
|
|
749
601
|
return top ? body : `(${body})`;
|
|
750
602
|
};
|
|
@@ -760,6 +612,858 @@ const serializeNode = (node, top) => {
|
|
|
760
612
|
*/
|
|
761
613
|
const serializeCondition = (node) => serializeNode(node, true);
|
|
762
614
|
//#endregion
|
|
615
|
+
//#region src/markers.ts
|
|
616
|
+
/**
|
|
617
|
+
* Canonical grammar for the docxtpl dialect of Jinja — the single source of
|
|
618
|
+
* truth shared by the fill pipeline (api), the editor highlighter (folio), and
|
|
619
|
+
* the read-only preview (web).
|
|
620
|
+
*
|
|
621
|
+
* Two spans carry meaning: an output marker `{{ ... }}` prints a value, and a
|
|
622
|
+
* tag `{% ... %}` opens or closes a block. A tag may carry a docxtpl placement
|
|
623
|
+
* prefix — `{%p ... %}` owns its paragraph, `{%tr ... %}` owns its table row —
|
|
624
|
+
* and a bare tag is placed by the leniency rules the consumer applies.
|
|
625
|
+
*
|
|
626
|
+
* Add a new directive to `MarkerMeta` and teach {@link classifyMarker} to
|
|
627
|
+
* recognize it. Compile-time totality checks then require its ordered runtime
|
|
628
|
+
* value and its block/inline disposition, while exhaustive consumer switches
|
|
629
|
+
* fail until they handle it.
|
|
630
|
+
*
|
|
631
|
+
* No consumer should define its own marker regex. The low-level pattern
|
|
632
|
+
* factories below exist so handlers that mutate raw XML/text reuse the exact
|
|
633
|
+
* same literals; recognizers should prefer the high-level scanner.
|
|
634
|
+
*/
|
|
635
|
+
/** The loop properties addressable inside a `{% for %}` body. */
|
|
636
|
+
const LOOP_PROPERTIES = [
|
|
637
|
+
"index",
|
|
638
|
+
"index0",
|
|
639
|
+
"first",
|
|
640
|
+
"last",
|
|
641
|
+
"length"
|
|
642
|
+
];
|
|
643
|
+
const DIRECTIVE_KINDS = [
|
|
644
|
+
"placeholder",
|
|
645
|
+
"clause",
|
|
646
|
+
"num",
|
|
647
|
+
"ref",
|
|
648
|
+
"loop",
|
|
649
|
+
"if",
|
|
650
|
+
"elif",
|
|
651
|
+
"else",
|
|
652
|
+
"endif",
|
|
653
|
+
"for",
|
|
654
|
+
"endfor"
|
|
655
|
+
];
|
|
656
|
+
/** Directives that occupy their own paragraph, in canonical grammar order. */
|
|
657
|
+
const BLOCK_DIRECTIVE_KINDS = [
|
|
658
|
+
"if",
|
|
659
|
+
"elif",
|
|
660
|
+
"else",
|
|
661
|
+
"endif",
|
|
662
|
+
"for",
|
|
663
|
+
"endfor"
|
|
664
|
+
];
|
|
665
|
+
const isBlockDirectiveKind = (kind) => typeof kind === "string" && BLOCK_DIRECTIVE_KINDS.some((blockKind) => blockKind === kind);
|
|
666
|
+
/** Callable forms inside `{{ }}` that address document machinery rather than a
|
|
667
|
+
* fill value. */
|
|
668
|
+
const FUNCTION_NAMES = [
|
|
669
|
+
"clause",
|
|
670
|
+
"num",
|
|
671
|
+
"ref"
|
|
672
|
+
];
|
|
673
|
+
/**
|
|
674
|
+
* Every filter the dialect accepts on a fillable value. The names are the
|
|
675
|
+
* grammar's; which manifest property each one writes is decided by the field
|
|
676
|
+
* catalogue in the api, which is total over BOTH this list and the manifest
|
|
677
|
+
* keys, so neither side can gain an entry the other has not decided.
|
|
678
|
+
*/
|
|
679
|
+
const FILTER_NAMES = [
|
|
680
|
+
"text",
|
|
681
|
+
"number",
|
|
682
|
+
"date",
|
|
683
|
+
"checkbox",
|
|
684
|
+
"select",
|
|
685
|
+
"options_from",
|
|
686
|
+
"label",
|
|
687
|
+
"hint",
|
|
688
|
+
"required",
|
|
689
|
+
"pattern",
|
|
690
|
+
"min",
|
|
691
|
+
"max",
|
|
692
|
+
"min_length",
|
|
693
|
+
"max_length",
|
|
694
|
+
"min_items",
|
|
695
|
+
"max_items",
|
|
696
|
+
"ai",
|
|
697
|
+
"lookup",
|
|
698
|
+
"matter",
|
|
699
|
+
"contact",
|
|
700
|
+
"party",
|
|
701
|
+
"attorney",
|
|
702
|
+
"firm",
|
|
703
|
+
"formula",
|
|
704
|
+
"condition"
|
|
705
|
+
];
|
|
706
|
+
const isFilterName = (value) => FILTER_NAMES.some((name) => name === value);
|
|
707
|
+
const isFunctionName = (value) => FUNCTION_NAMES.some((name) => name === value);
|
|
708
|
+
const SMART_DOUBLE_QUOTES = /[“”„«»]/gu;
|
|
709
|
+
const SMART_SINGLE_QUOTES = /[‘’‚]/gu;
|
|
710
|
+
const FIXED_SPACES = /[ ]/gu;
|
|
711
|
+
/** Normalise one marker's inner text: typographic quotes become straight
|
|
712
|
+
* quotes, fixed-width spaces become ordinary ones. */
|
|
713
|
+
const normalizeMarkerInner = (inner) => inner.replace(SMART_DOUBLE_QUOTES, "\"").replace(SMART_SINGLE_QUOTES, "'").replace(FIXED_SPACES, " ");
|
|
714
|
+
/**
|
|
715
|
+
* What an output marker holds between its braces: anything but a brace or a
|
|
716
|
+
* double quote, plus whole double-quoted arguments, whose braces are content
|
|
717
|
+
* rather than the marker's own delimiters — `pattern("^[0-9]{5}$")` is a regex,
|
|
718
|
+
* not a nested marker. An apostrophe stays an ordinary character, so
|
|
719
|
+
* `label("Owner's")` reads as it always did.
|
|
720
|
+
*
|
|
721
|
+
* A quoted run honours `\`-escapes, the way the argument scanner reads them,
|
|
722
|
+
* so `label("she said \"yes\"")` is one span rather than three: the span and
|
|
723
|
+
* the scanner have to agree on where a literal ends or a marker the scanner
|
|
724
|
+
* would parse is one the recognizer never sees.
|
|
725
|
+
*
|
|
726
|
+
* The alternatives cannot overlap (the plain one excludes every character the
|
|
727
|
+
* quoted one may start with, and inside a quoted run the escape branch is the
|
|
728
|
+
* only one that starts with a backslash), so the scan stays linear on
|
|
729
|
+
* adversarial input: a `{{` with no closing `}}` costs one failed pass, not a
|
|
730
|
+
* polynomial retry.
|
|
731
|
+
*/
|
|
732
|
+
const MARKER_OUTPUT_BODY = String.raw`(?:[^{}"“”„«»]|["“„«](?:\\[\s\S]|[^"”»\\])*["”»])*`;
|
|
733
|
+
/** What a tag holds between `{%` and `%}`: anything but a brace, and a `%`
|
|
734
|
+
* only where it does not close the tag. */
|
|
735
|
+
const MARKER_STATEMENT_BODY = String.raw`(?:[^{}%]|%(?!\}))*`;
|
|
736
|
+
/**
|
|
737
|
+
* Any marker span: an output `{{...}}` (group `output`) or a tag `{%...%}`
|
|
738
|
+
* (group `statement`, with the docxtpl placement in group `prefix`).
|
|
739
|
+
*/
|
|
740
|
+
const markerPattern = () => new RegExp(String.raw`\{\{(?<output>${MARKER_OUTPUT_BODY})\}\}|\{%(?:(?<prefix>tr|p)(?=\s))?(?<statement>${MARKER_STATEMENT_BODY})%\}`, "gu");
|
|
741
|
+
/** Inline value marker, including its filter chain; group `name` is the path
|
|
742
|
+
* the value comes from and group `filters` the untouched chain. */
|
|
743
|
+
const placeholderPattern = () => new RegExp(String.raw`\{\{\s*(?<name>[\p{L}\p{N}_.-]+)\s*(?<filters>\|${MARKER_OUTPUT_BODY})?\}\}`, "gu");
|
|
744
|
+
/** Any character that opens a quoted function argument. */
|
|
745
|
+
const OPEN_QUOTE = String.raw`["'“„«‘‚]`;
|
|
746
|
+
/** Any character that closes one. */
|
|
747
|
+
const CLOSE_QUOTE = String.raw`["'”»’]`;
|
|
748
|
+
/** Everything a quoted argument may hold: no quote of either shape. */
|
|
749
|
+
const QUOTED_BODY = String.raw`[^"'“”„«»‘’‚]*`;
|
|
750
|
+
/** `{{ clause("Name") }}` / `{{ clause("Name", "v3") }}`. */
|
|
751
|
+
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");
|
|
752
|
+
const numberingPattern = (fn) => new RegExp(String.raw`\{\{\s*${fn}\(\s*${OPEN_QUOTE}(?<key>[\p{L}\p{N}_.-]+)${CLOSE_QUOTE}\s*\)\s*\}\}`, "gu");
|
|
753
|
+
/** `{{ num("key") }}` — the `key` group. */
|
|
754
|
+
const numPattern = () => numberingPattern("num");
|
|
755
|
+
/** `{{ ref("key") }}` — the `key` group. */
|
|
756
|
+
const refPattern = () => numberingPattern("ref");
|
|
757
|
+
/** `{{ loop.index }}` and its siblings — the `property` group. */
|
|
758
|
+
const loopPattern = () => /\{\{\s*loop\.(?<property>index0|index|first|last|length)\s*\}\}/gu;
|
|
759
|
+
/** Cheap presence test for any numbering marker (no capture). */
|
|
760
|
+
const hasNumberingPattern = () => /\{\{\s*(?:num|ref)\s*\(/u;
|
|
761
|
+
/** Cheap presence test for any tag (no capture). */
|
|
762
|
+
const hasBlockDirectivePattern = () => /\{%/u;
|
|
763
|
+
/** A whole line that is a single block tag — `prefix`, `tag` and `expr`. */
|
|
764
|
+
const blockDirectiveLinePattern = () => /^\s*\{%(?:(?<prefix>tr|p)(?=\s))?\s*(?<tag>if|elif|else|endif|for|endfor)\b(?<expr>(?:[^{}%]|%(?!\}))*)%\}\s*$/u;
|
|
765
|
+
const FIELD_PATH_RE = /^[\p{L}\p{N}_.-]+$/u;
|
|
766
|
+
const IDENTIFIER_RE = /^[\p{L}_][\p{L}\p{N}_-]*$/u;
|
|
767
|
+
const UNSAFE_FIELD_PATH_SEGMENTS = /* @__PURE__ */ new Set([
|
|
768
|
+
"__proto__",
|
|
769
|
+
"constructor",
|
|
770
|
+
"prototype"
|
|
771
|
+
]);
|
|
772
|
+
/** Whether `value` is a valid field path per the marker grammar (dotted
|
|
773
|
+
* segments of letters/digits/underscore/dash — no brackets or spaces). */
|
|
774
|
+
const isFieldPath = (value) => FIELD_PATH_RE.test(value);
|
|
775
|
+
/** Whether `value` is safe to use as a dotted object path. */
|
|
776
|
+
const isSafeFieldPath = (value) => isFieldPath(value) && value.split(".").every((segment) => !UNSAFE_FIELD_PATH_SEGMENTS.has(segment));
|
|
777
|
+
const CLAUSE_SLOT_NAME_RE = /^[\p{L}\p{N}_.-]+$/u;
|
|
778
|
+
/** Whether `value` is a valid name for authoring a `clause("NAME")` slot. */
|
|
779
|
+
const isClauseSlotName = (value) => CLAUSE_SLOT_NAME_RE.test(value);
|
|
780
|
+
const isLoopProperty = (value) => LOOP_PROPERTIES.some((property) => property === value);
|
|
781
|
+
const NUMBER_LITERAL_RE = /^-?\d+(?:\.\d+)?$/u;
|
|
782
|
+
const literalFromBareToken = (raw) => {
|
|
783
|
+
if (raw === "true") return true;
|
|
784
|
+
if (raw === "false") return false;
|
|
785
|
+
if (NUMBER_LITERAL_RE.test(raw)) return Number(raw);
|
|
786
|
+
return null;
|
|
787
|
+
};
|
|
788
|
+
/**
|
|
789
|
+
* Scan a `"..."` / `'...'` literal starting at the opening quote, honouring
|
|
790
|
+
* `\`-escapes. One linear pass; an unterminated literal closes at end of
|
|
791
|
+
* input, matching the degrade-gracefully policy of the condition parser.
|
|
792
|
+
*/
|
|
793
|
+
const scanQuoted = (text, start) => {
|
|
794
|
+
const quote = text[start];
|
|
795
|
+
let i = start + 1;
|
|
796
|
+
let value = "";
|
|
797
|
+
while (i < text.length) {
|
|
798
|
+
const ch = text[i];
|
|
799
|
+
if (ch === quote) return {
|
|
800
|
+
value,
|
|
801
|
+
end: i + 1
|
|
802
|
+
};
|
|
803
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
804
|
+
value += text[i + 1];
|
|
805
|
+
i += 2;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
value += ch;
|
|
809
|
+
i += 1;
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
value,
|
|
813
|
+
end: text.length
|
|
814
|
+
};
|
|
815
|
+
};
|
|
816
|
+
/**
|
|
817
|
+
* Scan a parenthesised argument list starting at `(`. Arguments are string,
|
|
818
|
+
* number or boolean literals, optionally named (`adapt=true`). Anything else
|
|
819
|
+
* (an identifier, an operator, a nested call) fails the scan, which is what
|
|
820
|
+
* makes `min(a + 1)` a rejected expression rather than a silently odd filter.
|
|
821
|
+
*/
|
|
822
|
+
const scanArguments = (text, start) => {
|
|
823
|
+
const args = [];
|
|
824
|
+
let i = start + 1;
|
|
825
|
+
let expectValue = true;
|
|
826
|
+
let pendingName;
|
|
827
|
+
while (i < text.length) {
|
|
828
|
+
const ch = text[i];
|
|
829
|
+
if (ch === void 0) break;
|
|
830
|
+
if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
|
|
831
|
+
i += 1;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (ch === ")") {
|
|
835
|
+
if (pendingName !== void 0) return null;
|
|
836
|
+
if (!expectValue || args.length === 0) return {
|
|
837
|
+
args,
|
|
838
|
+
end: i + 1
|
|
839
|
+
};
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
if (ch === ",") {
|
|
843
|
+
if (expectValue) return null;
|
|
844
|
+
expectValue = true;
|
|
845
|
+
i += 1;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (!expectValue) return null;
|
|
849
|
+
if (ch === "\"" || ch === "'") {
|
|
850
|
+
const { value, end } = scanQuoted(text, i);
|
|
851
|
+
args.push(pendingName === void 0 ? {
|
|
852
|
+
kind: "positional",
|
|
853
|
+
value
|
|
854
|
+
} : {
|
|
855
|
+
kind: "keyword",
|
|
856
|
+
name: pendingName,
|
|
857
|
+
value
|
|
858
|
+
});
|
|
859
|
+
pendingName = void 0;
|
|
860
|
+
expectValue = false;
|
|
861
|
+
i = end;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
let j = i;
|
|
865
|
+
while (j < text.length && /[\p{L}\p{N}_.-]/u.test(text[j] ?? "")) j += 1;
|
|
866
|
+
if (j === i) return null;
|
|
867
|
+
const raw = text.slice(i, j);
|
|
868
|
+
if (text[j] === "=" && text[j + 1] !== "=") {
|
|
869
|
+
if (pendingName !== void 0 || !IDENTIFIER_RE.test(raw)) return null;
|
|
870
|
+
pendingName = raw;
|
|
871
|
+
i = j + 1;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const value = literalFromBareToken(raw);
|
|
875
|
+
if (value === null) return null;
|
|
876
|
+
args.push(pendingName === void 0 ? {
|
|
877
|
+
kind: "positional",
|
|
878
|
+
value
|
|
879
|
+
} : {
|
|
880
|
+
kind: "keyword",
|
|
881
|
+
name: pendingName,
|
|
882
|
+
value
|
|
883
|
+
});
|
|
884
|
+
pendingName = void 0;
|
|
885
|
+
expectValue = false;
|
|
886
|
+
i = j;
|
|
887
|
+
}
|
|
888
|
+
return null;
|
|
889
|
+
};
|
|
890
|
+
/**
|
|
891
|
+
* Parse the `| name | name(args)` tail of an output marker. Returns the named
|
|
892
|
+
* unknown filter instead of a chain when one step is not in the catalogue, so
|
|
893
|
+
* the rejection can quote it.
|
|
894
|
+
*/
|
|
895
|
+
const scanFilterChain = (tail) => {
|
|
896
|
+
const filters = [];
|
|
897
|
+
let i = 0;
|
|
898
|
+
while (i < tail.length) {
|
|
899
|
+
while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
|
|
900
|
+
if (i >= tail.length) break;
|
|
901
|
+
if (tail[i] !== "|") return null;
|
|
902
|
+
i += 1;
|
|
903
|
+
while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
|
|
904
|
+
let j = i;
|
|
905
|
+
while (j < tail.length && /[\p{L}\p{N}_]/u.test(tail[j] ?? "")) j += 1;
|
|
906
|
+
const name = tail.slice(i, j);
|
|
907
|
+
if (name === "") return null;
|
|
908
|
+
if (!isFilterName(name)) return { unknownFilter: name };
|
|
909
|
+
i = j;
|
|
910
|
+
while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
|
|
911
|
+
if (tail[i] === "(") {
|
|
912
|
+
const scanned = scanArguments(tail, i);
|
|
913
|
+
if (scanned === null) return null;
|
|
914
|
+
filters.push({
|
|
915
|
+
name,
|
|
916
|
+
args: scanned.args
|
|
917
|
+
});
|
|
918
|
+
i = scanned.end;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
filters.push({
|
|
922
|
+
name,
|
|
923
|
+
args: []
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
return { filters };
|
|
927
|
+
};
|
|
928
|
+
const PREFIX_BY_TOKEN = {
|
|
929
|
+
p: "paragraph",
|
|
930
|
+
tr: "row"
|
|
931
|
+
};
|
|
932
|
+
const readPrefix = (token) => token === "p" || token === "tr" ? PREFIX_BY_TOKEN[token] : "none";
|
|
933
|
+
const TAG_RE = /^(?<tag>[\p{L}_][\p{L}\p{N}_]*)\b(?<rest>[\s\S]*)$/u;
|
|
934
|
+
const FOR_RE = /^(?<alias>[\p{L}_][\p{L}\p{N}_-]*)\s+in\s+(?<path>[\p{L}\p{N}_.-]+)\s*(?<filters>\|[\s\S]*)?$/u;
|
|
935
|
+
const CALL_RE = /^(?<name>[\p{L}_][\p{L}\p{N}_]*)\s*\(/u;
|
|
936
|
+
/** Classify a `{% ... %}` tag. */
|
|
937
|
+
const classifyStatement = (inner) => {
|
|
938
|
+
const match = TAG_RE.exec(inner);
|
|
939
|
+
if (!match) return null;
|
|
940
|
+
const tag = match.groups?.["tag"] ?? "";
|
|
941
|
+
const rest = (match.groups?.["rest"] ?? "").trim();
|
|
942
|
+
switch (tag) {
|
|
943
|
+
case "if": return {
|
|
944
|
+
kind: "if",
|
|
945
|
+
expr: rest
|
|
946
|
+
};
|
|
947
|
+
case "elif": return {
|
|
948
|
+
kind: "elif",
|
|
949
|
+
expr: rest
|
|
950
|
+
};
|
|
951
|
+
case "else": return rest === "" ? { kind: "else" } : null;
|
|
952
|
+
case "endif": return rest === "" ? { kind: "endif" } : null;
|
|
953
|
+
case "endfor": return rest === "" ? { kind: "endfor" } : null;
|
|
954
|
+
case "for": {
|
|
955
|
+
const loop = FOR_RE.exec(rest);
|
|
956
|
+
if (!loop) return null;
|
|
957
|
+
const tail = loop.groups?.["filters"];
|
|
958
|
+
const chain = tail === void 0 ? { filters: [] } : scanFilterChain(tail);
|
|
959
|
+
if (chain === null || "unknownFilter" in chain) return null;
|
|
960
|
+
return {
|
|
961
|
+
kind: "for",
|
|
962
|
+
alias: loop.groups?.["alias"] ?? "",
|
|
963
|
+
path: loop.groups?.["path"] ?? "",
|
|
964
|
+
filters: chain.filters
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
default: return null;
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
/** Classify a `{{ ... }}` output marker. */
|
|
971
|
+
const classifyOutput = (inner) => {
|
|
972
|
+
const call = CALL_RE.exec(inner);
|
|
973
|
+
if (call) {
|
|
974
|
+
const name = call.groups?.["name"] ?? "";
|
|
975
|
+
if (!isFunctionName(name)) return null;
|
|
976
|
+
const scanned = scanArguments(inner, inner.indexOf("("));
|
|
977
|
+
if (scanned === null) return null;
|
|
978
|
+
if (inner.slice(scanned.end).trim() !== "") return null;
|
|
979
|
+
const positional = scanned.args.filter((arg) => arg.kind === "positional" && typeof arg.value === "string");
|
|
980
|
+
if (positional.length !== scanned.args.length) return null;
|
|
981
|
+
const [first, second] = positional.map((arg) => String(arg.value));
|
|
982
|
+
if (first === void 0) return null;
|
|
983
|
+
if (name === "clause") return scanned.args.length <= 2 ? {
|
|
984
|
+
kind: "clause",
|
|
985
|
+
name: first,
|
|
986
|
+
version: second
|
|
987
|
+
} : null;
|
|
988
|
+
if (scanned.args.length !== 1 || !isFieldPath(first)) return null;
|
|
989
|
+
return name === "num" ? {
|
|
990
|
+
kind: "num",
|
|
991
|
+
key: first
|
|
992
|
+
} : {
|
|
993
|
+
kind: "ref",
|
|
994
|
+
key: first
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
const pipe = inner.indexOf("|");
|
|
998
|
+
const head = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
|
|
999
|
+
const tail = pipe === -1 ? "" : inner.slice(pipe);
|
|
1000
|
+
const loopMatch = /^loop\.(?<property>[\p{L}\p{N}_]+)$/u.exec(head);
|
|
1001
|
+
if (loopMatch) {
|
|
1002
|
+
const property = loopMatch.groups?.["property"] ?? "";
|
|
1003
|
+
return isLoopProperty(property) && tail === "" ? {
|
|
1004
|
+
kind: "loop",
|
|
1005
|
+
property
|
|
1006
|
+
} : null;
|
|
1007
|
+
}
|
|
1008
|
+
if (!FIELD_PATH_RE.test(head)) return null;
|
|
1009
|
+
if (tail === "") return {
|
|
1010
|
+
kind: "placeholder",
|
|
1011
|
+
expr: head,
|
|
1012
|
+
filters: []
|
|
1013
|
+
};
|
|
1014
|
+
const chain = scanFilterChain(tail);
|
|
1015
|
+
if (chain === null || "unknownFilter" in chain) return null;
|
|
1016
|
+
return {
|
|
1017
|
+
kind: "placeholder",
|
|
1018
|
+
expr: head,
|
|
1019
|
+
filters: chain.filters
|
|
1020
|
+
};
|
|
1021
|
+
};
|
|
1022
|
+
/**
|
|
1023
|
+
* Classify the inner text of one marker. Returns `null` when the text is not a
|
|
1024
|
+
* recognized directive (so a recognizer can leave stray braces alone).
|
|
1025
|
+
*/
|
|
1026
|
+
const classifyMarker = (innerRaw, form = "output") => {
|
|
1027
|
+
const inner = normalizeMarkerInner(innerRaw).trim();
|
|
1028
|
+
return form === "statement" ? classifyStatement(inner) : classifyOutput(inner);
|
|
1029
|
+
};
|
|
1030
|
+
/** Every marker-shaped span in `text`, in document order, before classification. */
|
|
1031
|
+
const scanSpans = (text) => {
|
|
1032
|
+
const re = markerPattern();
|
|
1033
|
+
const spans = [];
|
|
1034
|
+
let match = re.exec(text);
|
|
1035
|
+
while (match !== null) {
|
|
1036
|
+
const output = match.groups?.["output"];
|
|
1037
|
+
const statement = match.groups?.["statement"];
|
|
1038
|
+
const raw = match[0];
|
|
1039
|
+
const form = output === void 0 ? "statement" : "output";
|
|
1040
|
+
spans.push({
|
|
1041
|
+
start: match.index,
|
|
1042
|
+
end: match.index + raw.length,
|
|
1043
|
+
raw,
|
|
1044
|
+
inner: normalizeMarkerInner(output ?? statement ?? "").trim(),
|
|
1045
|
+
form,
|
|
1046
|
+
prefix: readPrefix(match.groups?.["prefix"])
|
|
1047
|
+
});
|
|
1048
|
+
match = re.exec(text);
|
|
1049
|
+
}
|
|
1050
|
+
return spans;
|
|
1051
|
+
};
|
|
1052
|
+
/**
|
|
1053
|
+
* Find every recognized marker in `text`, in document order, with offsets and
|
|
1054
|
+
* classified metadata. Unrecognized spans are skipped.
|
|
1055
|
+
*/
|
|
1056
|
+
const scanMarkers = (text) => {
|
|
1057
|
+
const out = [];
|
|
1058
|
+
for (const span of scanSpans(text)) {
|
|
1059
|
+
const meta = classifyMarker(span.inner, span.form);
|
|
1060
|
+
if (meta) out.push({
|
|
1061
|
+
...span,
|
|
1062
|
+
meta
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
return out;
|
|
1066
|
+
};
|
|
1067
|
+
/**
|
|
1068
|
+
* Rewrite every `{{ ... }}` output marker in `text` through `replace`, which
|
|
1069
|
+
* receives the whole marker and its raw inner text and returns the replacement.
|
|
1070
|
+
* Tags are left untouched. The single scan is the same one the classifier uses,
|
|
1071
|
+
* so a caller that rewrites marker text never needs its own regex.
|
|
1072
|
+
*/
|
|
1073
|
+
const replaceOutputMarkers = (text, replace) => {
|
|
1074
|
+
let out = "";
|
|
1075
|
+
let cursor = 0;
|
|
1076
|
+
for (const span of scanSpans(text)) {
|
|
1077
|
+
if (span.form !== "output") continue;
|
|
1078
|
+
out += text.slice(cursor, span.start) + replace(span.raw, span.inner);
|
|
1079
|
+
cursor = span.end;
|
|
1080
|
+
}
|
|
1081
|
+
return out + text.slice(cursor);
|
|
1082
|
+
};
|
|
1083
|
+
/**
|
|
1084
|
+
* The values-map key one marker substitutes from, or `null` when the marker is
|
|
1085
|
+
* not a substitution target (a tag, or a `loop.*` token the loop expander
|
|
1086
|
+
* resolves first). One function so the key discovery writes is the key the
|
|
1087
|
+
* patcher reads.
|
|
1088
|
+
*/
|
|
1089
|
+
const substitutionKey = (meta) => {
|
|
1090
|
+
switch (meta.kind) {
|
|
1091
|
+
case "placeholder": return meta.expr;
|
|
1092
|
+
case "clause": return meta.version === void 0 ? `${CLAUSE_KEY_PREFIX}${meta.name}` : `${CLAUSE_KEY_PREFIX}${meta.name}:${meta.version}`;
|
|
1093
|
+
case "num":
|
|
1094
|
+
case "ref":
|
|
1095
|
+
case "loop":
|
|
1096
|
+
case "if":
|
|
1097
|
+
case "elif":
|
|
1098
|
+
case "else":
|
|
1099
|
+
case "endif":
|
|
1100
|
+
case "for":
|
|
1101
|
+
case "endfor": return null;
|
|
1102
|
+
default: return assertNever(meta);
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
/** Namespace of the synthetic clause-slot key, kept out of the field-path
|
|
1106
|
+
* charset so it can never collide with an author's path. */
|
|
1107
|
+
const CLAUSE_KEY_PREFIX = "@clause:";
|
|
1108
|
+
/** The values-map key for one clause slot. */
|
|
1109
|
+
const clauseSlotKey = (name, version) => version === void 0 ? `${CLAUSE_KEY_PREFIX}${name}` : `${CLAUSE_KEY_PREFIX}${name}:${version}`;
|
|
1110
|
+
/**
|
|
1111
|
+
* Find every marker-shaped span whose inner text is NOT a recognized
|
|
1112
|
+
* directive — near-misses an author clearly meant as markers but that every
|
|
1113
|
+
* recognizer skips, so they print literally at fill time. This is the exact
|
|
1114
|
+
* complement of {@link scanMarkers}: a span is in one list or the other.
|
|
1115
|
+
*/
|
|
1116
|
+
const scanInvalidMarkers = (text) => {
|
|
1117
|
+
const out = [];
|
|
1118
|
+
for (const { prefix: _prefix, ...span } of scanSpans(text)) if (classifyMarker(span.inner, span.form) === null) out.push(span);
|
|
1119
|
+
return out;
|
|
1120
|
+
};
|
|
1121
|
+
const LEGACY_BLOCK_RE = /^(?<token>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[\s\S]*)$/u;
|
|
1122
|
+
const LEGACY_CLAUSE_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
|
|
1123
|
+
const LEGACY_NUM_RE = /^@(?<fn>num|ref):(?<key>[\p{L}\p{N}_.-]+)$/u;
|
|
1124
|
+
/** Singular of a plain-word path segment, for a generated loop alias. */
|
|
1125
|
+
const singularize = (segment) => {
|
|
1126
|
+
if (segment.endsWith("ies") && segment.length > 3) return `${segment.slice(0, -3)}y`;
|
|
1127
|
+
if (/(?:s|ss|sh|ch|x|z)es$/u.test(segment)) return segment.slice(0, -2);
|
|
1128
|
+
if (/[^s]s$/u.test(segment)) return segment.slice(0, -1);
|
|
1129
|
+
return segment;
|
|
1130
|
+
};
|
|
1131
|
+
/** The loop alias the codemod (and the legacy-marker message) generates for a
|
|
1132
|
+
* `{{#each path}}`: the singular of the last plain-word segment, else `item`. */
|
|
1133
|
+
const legacyLoopAlias = (path) => {
|
|
1134
|
+
const segment = path.split(".").at(-1) ?? "";
|
|
1135
|
+
if (!IDENTIFIER_RE.test(segment)) return "item";
|
|
1136
|
+
const alias = singularize(segment);
|
|
1137
|
+
return alias === "" || alias === segment ? "item" : alias;
|
|
1138
|
+
};
|
|
1139
|
+
/** Translate an old-dialect condition expression to the Jinja surface:
|
|
1140
|
+
* `!a` becomes `not a`, `a contains "x"` becomes `"x" in a`. */
|
|
1141
|
+
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 ");
|
|
1142
|
+
/**
|
|
1143
|
+
* The Jinja marker that replaces one old-dialect marker's inner text, or
|
|
1144
|
+
* `null` when the text is not old-dialect. Shared by the rejection message and
|
|
1145
|
+
* the codemod, so the fix an author is told to make is the fix the script
|
|
1146
|
+
* would have made.
|
|
1147
|
+
*/
|
|
1148
|
+
const legacyMarkerReplacement = (innerRaw) => {
|
|
1149
|
+
const inner = normalizeMarkerInner(innerRaw).trim();
|
|
1150
|
+
const block = LEGACY_BLOCK_RE.exec(inner);
|
|
1151
|
+
if (block) {
|
|
1152
|
+
const token = block.groups?.["token"] ?? "";
|
|
1153
|
+
const expr = translateLegacyExpression((block.groups?.["expr"] ?? "").trim());
|
|
1154
|
+
switch (token) {
|
|
1155
|
+
case "#if": return `{% if ${expr} %}`;
|
|
1156
|
+
case "#elseif": return `{% elif ${expr} %}`;
|
|
1157
|
+
case "#else": return "{% else %}";
|
|
1158
|
+
case "/if": return "{% endif %}";
|
|
1159
|
+
case "#each": return `{% for ${legacyLoopAlias(expr)} in ${expr} %}`;
|
|
1160
|
+
case "/each": return "{% endfor %}";
|
|
1161
|
+
default: return null;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const clause = LEGACY_CLAUSE_RE.exec(inner);
|
|
1165
|
+
if (clause) {
|
|
1166
|
+
const name = clause.groups?.["name"] ?? "";
|
|
1167
|
+
const version = clause.groups?.["version"];
|
|
1168
|
+
return version === void 0 ? `{{ clause("${name}") }}` : `{{ clause("${name}", "${version}") }}`;
|
|
1169
|
+
}
|
|
1170
|
+
const numbering = LEGACY_NUM_RE.exec(inner);
|
|
1171
|
+
if (numbering) return `{{ ${numbering.groups?.["fn"] ?? ""}("${numbering.groups?.["key"] ?? ""}") }}`;
|
|
1172
|
+
if (inner === "@index") return "{{ loop.index }}";
|
|
1173
|
+
if (inner === "@count") return "{{ loop.length }}";
|
|
1174
|
+
return null;
|
|
1175
|
+
};
|
|
1176
|
+
const MARKER_DEFECT_KINDS = [
|
|
1177
|
+
"legacy_marker",
|
|
1178
|
+
"unsupported_tag",
|
|
1179
|
+
"unknown_filter",
|
|
1180
|
+
"python_expression",
|
|
1181
|
+
"bracket_index"
|
|
1182
|
+
];
|
|
1183
|
+
/** Bracket indexing (`items[0].name`), which no directive kind admits. */
|
|
1184
|
+
const BRACKET_INDEX_RE = /[[\]]/u;
|
|
1185
|
+
/** A legacy directive sigil at the head of an output marker. */
|
|
1186
|
+
const LEGACY_SHAPE_RE = /^[#/@]/u;
|
|
1187
|
+
/**
|
|
1188
|
+
* Arithmetic, a call, a quoted string or a Python literal inside `{{ }}`. A
|
|
1189
|
+
* span that merely holds a space (`{{my field}}`) is a mistyped path, not an
|
|
1190
|
+
* expression, and stays undiagnosed so the check reports it as an unreadable
|
|
1191
|
+
* marker rather than guessing at Python.
|
|
1192
|
+
*/
|
|
1193
|
+
const PYTHON_SHAPE_RE = /["'()+*/%]|\bNone\b|\bTrue\b|\bFalse\b/u;
|
|
1194
|
+
/**
|
|
1195
|
+
* Name the authoring mistake behind one marker's inner text, or `null` when
|
|
1196
|
+
* the text is a recognized directive ({@link classifyMarker} accepts it) or an
|
|
1197
|
+
* unrecognizable span with no specific diagnosis (`{{my field}}`).
|
|
1198
|
+
*/
|
|
1199
|
+
const classifyMarkerDefect = (innerRaw, form = "output") => {
|
|
1200
|
+
const inner = normalizeMarkerInner(innerRaw).trim();
|
|
1201
|
+
if (classifyMarker(inner, form) !== null) return null;
|
|
1202
|
+
if (form === "statement") {
|
|
1203
|
+
const tag = TAG_RE.exec(inner)?.groups?.["tag"];
|
|
1204
|
+
if (tag === void 0) return null;
|
|
1205
|
+
const legacy = legacyMarkerReplacement(inner);
|
|
1206
|
+
if (legacy !== null) return {
|
|
1207
|
+
kind: "legacy_marker",
|
|
1208
|
+
construct: inner,
|
|
1209
|
+
replacement: legacy
|
|
1210
|
+
};
|
|
1211
|
+
return {
|
|
1212
|
+
kind: "unsupported_tag",
|
|
1213
|
+
construct: tag
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
const legacy = legacyMarkerReplacement(inner);
|
|
1217
|
+
if (legacy !== null) return {
|
|
1218
|
+
kind: "legacy_marker",
|
|
1219
|
+
construct: inner,
|
|
1220
|
+
replacement: legacy
|
|
1221
|
+
};
|
|
1222
|
+
if (LEGACY_SHAPE_RE.test(inner)) return {
|
|
1223
|
+
kind: "legacy_marker",
|
|
1224
|
+
construct: inner
|
|
1225
|
+
};
|
|
1226
|
+
if (BRACKET_INDEX_RE.test(inner)) return {
|
|
1227
|
+
kind: "bracket_index",
|
|
1228
|
+
construct: inner
|
|
1229
|
+
};
|
|
1230
|
+
const pipe = inner.indexOf("|");
|
|
1231
|
+
if (pipe !== -1) {
|
|
1232
|
+
const chain = scanFilterChain(inner.slice(pipe));
|
|
1233
|
+
if (chain !== null && "unknownFilter" in chain) return {
|
|
1234
|
+
kind: "unknown_filter",
|
|
1235
|
+
construct: chain.unknownFilter
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
const call = CALL_RE.exec(inner);
|
|
1239
|
+
if (call) {
|
|
1240
|
+
const name = call.groups?.["name"] ?? "";
|
|
1241
|
+
if (!isFunctionName(name)) return {
|
|
1242
|
+
kind: "python_expression",
|
|
1243
|
+
construct: `${name}(...)`
|
|
1244
|
+
};
|
|
1245
|
+
return {
|
|
1246
|
+
kind: "python_expression",
|
|
1247
|
+
construct: inner
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
const head = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
|
|
1251
|
+
if (head !== "" && !FIELD_PATH_RE.test(head) && PYTHON_SHAPE_RE.test(head)) return {
|
|
1252
|
+
kind: "python_expression",
|
|
1253
|
+
construct: head
|
|
1254
|
+
};
|
|
1255
|
+
return null;
|
|
1256
|
+
};
|
|
1257
|
+
/** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
|
|
1258
|
+
const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
|
|
1259
|
+
//#endregion
|
|
1260
|
+
//#region src/loop-scope.ts
|
|
1261
|
+
const rowScopePaths = (rowScopes) => rowScopes.map(({ scopedPath }) => scopedPath);
|
|
1262
|
+
/** A path already under the innermost repeat is left alone; anything else is
|
|
1263
|
+
* read as a name relative to it. */
|
|
1264
|
+
const qualifyRowScopedPath = (path, rowPaths = []) => {
|
|
1265
|
+
if (rowPaths.some((rowPath) => path === rowPath || path.startsWith(`${rowPath}.`))) return path;
|
|
1266
|
+
const innermostRowPath = rowPaths.at(-1);
|
|
1267
|
+
return innermostRowPath === void 0 ? path : `${innermostRowPath}.${path}`;
|
|
1268
|
+
};
|
|
1269
|
+
/** The manifest path for a marker written inside these loops. */
|
|
1270
|
+
const qualifyRowScopedPlaceholder = (path, rowScopes) => {
|
|
1271
|
+
if (rowScopes.some(({ scopedPath }) => path === scopedPath || path.startsWith(`${scopedPath}.`))) return path;
|
|
1272
|
+
for (const { alias, declaredPath, scopedPath } of rowScopes.toReversed()) for (const head of [alias, declaredPath]) {
|
|
1273
|
+
if (path === head) return scopedPath;
|
|
1274
|
+
if (path.startsWith(`${head}.`)) return `${scopedPath}.${path.slice(head.length + 1)}`;
|
|
1275
|
+
}
|
|
1276
|
+
return path;
|
|
1277
|
+
};
|
|
1278
|
+
/** The manifest path for an array a `{% for %}` opens inside these loops. */
|
|
1279
|
+
const qualifyLoopPath = (declaredPath, rowScopes) => qualifyRowScopedPath(qualifyRowScopedPlaceholder(declaredPath, rowScopes), rowScopePaths(rowScopes));
|
|
1280
|
+
//#endregion
|
|
1281
|
+
//#region src/field-config.ts
|
|
1282
|
+
/**
|
|
1283
|
+
* The filters that describe a REPEAT rather than a value: how many rows it
|
|
1284
|
+
* takes and what to call it. These are the ones a `{% for %}` tag may carry,
|
|
1285
|
+
* since the loop path names the array, not one of its values.
|
|
1286
|
+
*/
|
|
1287
|
+
const ARRAY_FILTER_NAMES = [
|
|
1288
|
+
"label",
|
|
1289
|
+
"hint",
|
|
1290
|
+
"required",
|
|
1291
|
+
"min_items",
|
|
1292
|
+
"max_items"
|
|
1293
|
+
];
|
|
1294
|
+
const isArrayFilterName = (name) => ARRAY_FILTER_NAMES.some((candidate) => candidate === name);
|
|
1295
|
+
const NO_FILTERS = [];
|
|
1296
|
+
const filterCall = (name, ...args) => ({
|
|
1297
|
+
name,
|
|
1298
|
+
args
|
|
1299
|
+
});
|
|
1300
|
+
const positionalArg = (value) => ({
|
|
1301
|
+
kind: "positional",
|
|
1302
|
+
value
|
|
1303
|
+
});
|
|
1304
|
+
const keywordArg = (name, value) => ({
|
|
1305
|
+
kind: "keyword",
|
|
1306
|
+
name,
|
|
1307
|
+
value
|
|
1308
|
+
});
|
|
1309
|
+
/** The `date()` spec that reads back as this pair: a locale, then the style it
|
|
1310
|
+
* renders with. An ISO date reads the same in every language, so `iso` is
|
|
1311
|
+
* written as the bare style the reader canonicalizes. */
|
|
1312
|
+
const dateSpec = ({ locale, style }) => style === "iso" ? style : `${locale}-${style}`;
|
|
1313
|
+
/** The numeric bounds, in the order the reader lists them, so a chain a
|
|
1314
|
+
* document already carries and one this writes are the same text. */
|
|
1315
|
+
const VALIDATION_BOUNDS = [
|
|
1316
|
+
["min", "min"],
|
|
1317
|
+
["max", "max"],
|
|
1318
|
+
["minLength", "min_length"],
|
|
1319
|
+
["maxLength", "max_length"],
|
|
1320
|
+
["minItems", "min_items"],
|
|
1321
|
+
["maxItems", "max_items"]
|
|
1322
|
+
];
|
|
1323
|
+
const writeInputType = (field) => {
|
|
1324
|
+
if (field.dateFormat !== void 0) return [filterCall("date", positionalArg(dateSpec(field.dateFormat)))];
|
|
1325
|
+
const { inputType } = field;
|
|
1326
|
+
if (inputType === void 0) return NO_FILTERS;
|
|
1327
|
+
switch (inputType) {
|
|
1328
|
+
case "boolean": return [filterCall("checkbox")];
|
|
1329
|
+
case "date": return [filterCall("date", positionalArg("iso"))];
|
|
1330
|
+
case "select": return [filterCall("select", ...(field.options ?? []).map((option) => positionalArg(option)))];
|
|
1331
|
+
case "number":
|
|
1332
|
+
case "text": return [filterCall(inputType)];
|
|
1333
|
+
default: return assertNever(inputType);
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
const writeSource = (field) => {
|
|
1337
|
+
const { source } = field;
|
|
1338
|
+
if (source === void 0) return NO_FILTERS;
|
|
1339
|
+
switch (source.kind) {
|
|
1340
|
+
case "party": return [filterCall("party", positionalArg(source.role), positionalArg(source.field))];
|
|
1341
|
+
case "attorney": return [filterCall("attorney", positionalArg(source.ref), positionalArg(source.field))];
|
|
1342
|
+
case "contact":
|
|
1343
|
+
case "firm":
|
|
1344
|
+
case "matter": return [filterCall(source.kind, positionalArg(source.field))];
|
|
1345
|
+
default: return assertNever(source);
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
const writeAi = (field) => {
|
|
1349
|
+
const { aiAdapt, aiPrompt, aiSeesDocument } = field;
|
|
1350
|
+
if (aiPrompt === void 0 && aiAdapt !== true) return NO_FILTERS;
|
|
1351
|
+
return [filterCall("ai", ...aiPrompt === void 0 ? [] : [positionalArg(aiPrompt)], ...aiAdapt === void 0 ? [] : [keywordArg("adapt", aiAdapt)], ...aiSeesDocument === void 0 ? [] : [keywordArg("sees_document", aiSeesDocument)])];
|
|
1352
|
+
};
|
|
1353
|
+
const writeValidation = (field) => {
|
|
1354
|
+
const validation = field.validation;
|
|
1355
|
+
if (validation === void 0) return NO_FILTERS;
|
|
1356
|
+
const calls = [];
|
|
1357
|
+
if (validation.pattern !== void 0) calls.push(filterCall("pattern", positionalArg(validation.pattern)));
|
|
1358
|
+
for (const [key, name] of VALIDATION_BOUNDS) {
|
|
1359
|
+
const bound = validation[key];
|
|
1360
|
+
if (bound !== void 0) calls.push(filterCall(name, positionalArg(bound)));
|
|
1361
|
+
}
|
|
1362
|
+
return calls;
|
|
1363
|
+
};
|
|
1364
|
+
/**
|
|
1365
|
+
* Every key of {@link MarkerFieldConfig} and the filters that write it. Total
|
|
1366
|
+
* over the shape, so a property cannot join it without a decision about how an
|
|
1367
|
+
* author writes it in the document.
|
|
1368
|
+
*
|
|
1369
|
+
* A filter several properties feed (`ai`, `select`, `date`) is written by one
|
|
1370
|
+
* of them and skipped by the rest; the round-trip property in the api's
|
|
1371
|
+
* `field-filters.property.test.ts` is what proves nothing is dropped.
|
|
1372
|
+
*/
|
|
1373
|
+
const MARKER_FIELD_FILTERS = {
|
|
1374
|
+
path: () => NO_FILTERS,
|
|
1375
|
+
inputType: writeInputType,
|
|
1376
|
+
options: () => NO_FILTERS,
|
|
1377
|
+
dateFormat: () => NO_FILTERS,
|
|
1378
|
+
optionsFrom: ({ optionsFrom }) => optionsFrom === void 0 ? NO_FILTERS : [filterCall("options_from", positionalArg(optionsFrom))],
|
|
1379
|
+
label: ({ label }) => label === void 0 ? NO_FILTERS : [filterCall("label", positionalArg(label))],
|
|
1380
|
+
hint: ({ hint }) => hint === void 0 ? NO_FILTERS : [filterCall("hint", positionalArg(hint))],
|
|
1381
|
+
required: (field) => field.required === true || field.validation?.required === true ? [filterCall("required")] : NO_FILTERS,
|
|
1382
|
+
validation: writeValidation,
|
|
1383
|
+
aiPrompt: writeAi,
|
|
1384
|
+
aiAdapt: () => NO_FILTERS,
|
|
1385
|
+
aiSeesDocument: () => NO_FILTERS,
|
|
1386
|
+
lookup: ({ lookup }) => lookup === void 0 ? NO_FILTERS : [filterCall("lookup", positionalArg(lookup.registry), ...lookup.formats.map(({ key, template }) => keywordArg(key, template)))],
|
|
1387
|
+
source: writeSource,
|
|
1388
|
+
formula: ({ formula }) => formula === void 0 ? NO_FILTERS : [filterCall("formula", positionalArg(formula))],
|
|
1389
|
+
condition: ({ condition }) => condition === void 0 ? NO_FILTERS : [filterCall("condition", positionalArg(condition))]
|
|
1390
|
+
};
|
|
1391
|
+
/**
|
|
1392
|
+
* The filter chain that declares this field in a document, in the catalogue's
|
|
1393
|
+
* own order so a chain the writer produces and one an author wrote read the
|
|
1394
|
+
* same.
|
|
1395
|
+
*/
|
|
1396
|
+
const filtersFromFieldConfig = (field) => Object.values(MARKER_FIELD_FILTERS).flatMap((write) => write(field));
|
|
1397
|
+
/**
|
|
1398
|
+
* The chain a `{% for %}` opener carries for this array: the repeat's own
|
|
1399
|
+
* filters and nothing else, derived from {@link ARRAY_FILTER_NAMES}, so a
|
|
1400
|
+
* value filter can never be written where the reader refuses it.
|
|
1401
|
+
*/
|
|
1402
|
+
const arrayFiltersFromFieldConfig = (field) => filtersFromFieldConfig(field).filter(({ name }) => isArrayFilterName(name));
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region src/render.ts
|
|
1405
|
+
/** The number literals the scanner reads: no exponent, no `Infinity`. */
|
|
1406
|
+
const WRITABLE_NUMBER = /^-?\d+(?:\.\d+)?$/u;
|
|
1407
|
+
/** What a tag's body cannot carry: it has no quoted-run escape hatch, so a
|
|
1408
|
+
* brace or a tag closer inside one would end the tag early. */
|
|
1409
|
+
const RESERVED_IN_TAG = /[{}]|%\}/u;
|
|
1410
|
+
/** The reason this literal cannot be written in a marker of this form, or
|
|
1411
|
+
* `null` when it can. A boolean always can; a string can in a `{{ }}` marker,
|
|
1412
|
+
* because the writer quotes and escapes it. */
|
|
1413
|
+
const unwritableMarkerLiteral = (value, form = "output") => {
|
|
1414
|
+
if (typeof value === "number") return WRITABLE_NUMBER.test(String(value)) ? null : "number-spelling";
|
|
1415
|
+
if (typeof value === "string" && form === "statement") return RESERVED_IN_TAG.test(value) ? "tag-delimiter" : null;
|
|
1416
|
+
return null;
|
|
1417
|
+
};
|
|
1418
|
+
/** One string literal, double-quoted, with backslashes and quotes escaped the
|
|
1419
|
+
* way `scanQuoted` reads them back. */
|
|
1420
|
+
const renderString = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
|
1421
|
+
const renderLiteral = (value) => typeof value === "string" ? renderString(value) : String(value);
|
|
1422
|
+
const renderArgument = (arg) => arg.kind === "positional" ? renderLiteral(arg.value) : `${arg.name}=${renderLiteral(arg.value)}`;
|
|
1423
|
+
/** Every value in a chain the grammar has no spelling for, with the filter it
|
|
1424
|
+
* was written on and why, so a caller can refuse a configuration before it
|
|
1425
|
+
* rewrites a document into something the scanner would read differently. A
|
|
1426
|
+
* repeat's chain goes in a tag, so pass `"statement"` for one. */
|
|
1427
|
+
const unwritableFilterValues = (filters, form = "output") => filters.flatMap(({ args, name }) => args.flatMap((arg) => {
|
|
1428
|
+
const reason = unwritableMarkerLiteral(arg.value, form);
|
|
1429
|
+
return reason === null ? [] : [{
|
|
1430
|
+
filter: name,
|
|
1431
|
+
value: arg.value,
|
|
1432
|
+
reason
|
|
1433
|
+
}];
|
|
1434
|
+
}));
|
|
1435
|
+
/**
|
|
1436
|
+
* One filter chain as marker text, without the leading pipe: `label("A") |
|
|
1437
|
+
* required`. An empty chain renders the empty string, which is what a marker
|
|
1438
|
+
* that configures nothing carries.
|
|
1439
|
+
*/
|
|
1440
|
+
const renderFilterChain = (filters) => filters.map(({ args, name }) => args.length === 0 ? name : `${name}(${args.map(renderArgument).join(", ")})`).join(" | ");
|
|
1441
|
+
/** One value marker: `{{ path }}` or `{{ path | filters }}`. */
|
|
1442
|
+
const renderValueMarker = (path, filters) => {
|
|
1443
|
+
const chain = renderFilterChain(filters);
|
|
1444
|
+
return chain === "" ? `{{ ${path} }}` : `{{ ${path} | ${chain} }}`;
|
|
1445
|
+
};
|
|
1446
|
+
/** The docxtpl placement token a prefix is written with; `none` writes none. */
|
|
1447
|
+
const PREFIX_TOKEN = {
|
|
1448
|
+
none: "",
|
|
1449
|
+
paragraph: "p",
|
|
1450
|
+
row: "tr"
|
|
1451
|
+
};
|
|
1452
|
+
/**
|
|
1453
|
+
* One `{% if %}` / `{% elif %}` opener carrying its expression.
|
|
1454
|
+
*
|
|
1455
|
+
* A rule that decides whether a block shows belongs in the tag that shows it:
|
|
1456
|
+
* the tag is the only place a document has for it, so a builder's rule is
|
|
1457
|
+
* serialized here rather than stored beside the document under a name.
|
|
1458
|
+
*/
|
|
1459
|
+
const renderConditionTag = ({ expression, kind, prefix }) => `{%${PREFIX_TOKEN[prefix]} ${kind} ${expression} %}`;
|
|
1460
|
+
/** One `{% for %}` opener, keeping its placement prefix. */
|
|
1461
|
+
const renderForOpener = ({ alias, filters, path, prefix }) => {
|
|
1462
|
+
const chain = renderFilterChain(filters);
|
|
1463
|
+
const head = `{%${PREFIX_TOKEN[prefix]} for ${alias} in ${path}`;
|
|
1464
|
+
return chain === "" ? `${head} %}` : `${head} | ${chain} %}`;
|
|
1465
|
+
};
|
|
1466
|
+
//#endregion
|
|
763
1467
|
//#region src/row-blocks.ts
|
|
764
1468
|
/**
|
|
765
1469
|
* Row-form block markers: the lenient placement authoring agents actually
|
|
@@ -770,7 +1474,7 @@ const serializeCondition = (node) => serializeNode(node, true);
|
|
|
770
1474
|
* an author instead types the opener in front of the first cell's text and the
|
|
771
1475
|
* closer behind the last cell's text:
|
|
772
1476
|
*
|
|
773
|
-
* | {
|
|
1477
|
+
* | {% for d in deliverables %}{{ d.item }} | {{ d.fee }}{% endfor %} |
|
|
774
1478
|
*
|
|
775
1479
|
* Both placements mean the same thing — the `w:tr` is the unit — so this
|
|
776
1480
|
* module recognizes the row form purely from text, and the consumer rewrites
|
|
@@ -781,18 +1485,18 @@ const serializeCondition = (node) => serializeNode(node, true);
|
|
|
781
1485
|
* and neither can drift from the other's idea of what a row block is.
|
|
782
1486
|
*/
|
|
783
1487
|
const CLOSER_OF = {
|
|
784
|
-
|
|
1488
|
+
for: "endfor",
|
|
785
1489
|
if: "endif"
|
|
786
1490
|
};
|
|
787
|
-
const isOpenKind = (kind) => kind === "
|
|
1491
|
+
const isOpenKind = (kind) => kind === "for" || kind === "if";
|
|
788
1492
|
/**
|
|
789
1493
|
* 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{
|
|
1494
|
+
* and closes within the paragraph is an inline span (`the Buyer{% if x %} and
|
|
1495
|
+
* spouse{% endif %}`), which the inline engine already owns; only what is left
|
|
792
1496
|
* over can reach across cells.
|
|
793
1497
|
*
|
|
794
|
-
* A branch marker (`{
|
|
795
|
-
* `{
|
|
1498
|
+
* A branch marker (`{% elif %}`, `{% else %}`) counts as left over unless its
|
|
1499
|
+
* `{% if %}` also closes in this paragraph. Only the opener and the closer of a
|
|
796
1500
|
* row block are hoisted, so a branch marker stranded between them would stay
|
|
797
1501
|
* buried in the cell text: a false condition would drop the whole row and the
|
|
798
1502
|
* branch that should have rendered with it, and a true one would leave the
|
|
@@ -816,13 +1520,13 @@ const danglingBlockMarkers = (text) => {
|
|
|
816
1520
|
});
|
|
817
1521
|
continue;
|
|
818
1522
|
}
|
|
819
|
-
if (kind === "
|
|
1523
|
+
if (kind === "endfor" || kind === "endif") {
|
|
820
1524
|
const innermost = open.at(-1);
|
|
821
1525
|
if (innermost && CLOSER_OF[innermost.kind] === kind) open.pop();
|
|
822
1526
|
else dangling.push(marker);
|
|
823
1527
|
continue;
|
|
824
1528
|
}
|
|
825
|
-
if (kind === "
|
|
1529
|
+
if (kind === "elif" || kind === "else") {
|
|
826
1530
|
const innermost = open.at(-1);
|
|
827
1531
|
if (innermost?.kind === "if") innermost.branches.push(marker);
|
|
828
1532
|
else dangling.push(marker);
|
|
@@ -847,7 +1551,7 @@ const suffixesCell = (paragraphs, { marker, paragraphIndex }) => paragraphIndex
|
|
|
847
1551
|
* paragraph, they are a matching opener/closer pair, the closer sits in a LATER
|
|
848
1552
|
* cell than the opener, the opener prefixes its cell and the closer suffixes
|
|
849
1553
|
* its cell. Everything else — one half of a pair, two nested pairs, a stray
|
|
850
|
-
* `{
|
|
1554
|
+
* `{% else %}`, a pair wrapping the paragraphs of a single cell — is left to the
|
|
851
1555
|
* engine's existing structure errors rather than guessed at.
|
|
852
1556
|
*/
|
|
853
1557
|
const detectRowBlockPair = (cells) => {
|
|
@@ -878,7 +1582,7 @@ const detectRowBlockPair = (cells) => {
|
|
|
878
1582
|
*
|
|
879
1583
|
* Boolean conditions are evaluated through the canonical `@stll/conditions`
|
|
880
1584
|
* AST and its single evaluator, so view filters, AI-extraction gating, and
|
|
881
|
-
* template `{
|
|
1585
|
+
* template `{% if ... %}` conditionals share one set of operators and semantics
|
|
882
1586
|
* and never drift apart. This module owns the template surface: the string
|
|
883
1587
|
* parser (`./parse`), the named-condition resolver, and the no-code builder
|
|
884
1588
|
* serializer (`./condition-builder`).
|
|
@@ -892,7 +1596,7 @@ const MAX_CONDITION_DEPTH = 50;
|
|
|
892
1596
|
/**
|
|
893
1597
|
* Normalize a fill-bag value into a `ConditionValue` the shared evaluator
|
|
894
1598
|
* accepts: arrays become string arrays (multi-select fields hold their options
|
|
895
|
-
* as strings, so membership tests like `
|
|
1599
|
+
* as strings, so membership tests like `"guarantor" in parties` work
|
|
896
1600
|
* even when an element is numeric), primitives pass through, anything else is
|
|
897
1601
|
* stringified.
|
|
898
1602
|
*/
|
|
@@ -905,7 +1609,7 @@ const toConditionValue = (raw) => {
|
|
|
905
1609
|
* Build the operand resolver for one evaluation. Only `path` operands need
|
|
906
1610
|
* resolving (the evaluator handles literals). A path is first matched against
|
|
907
1611
|
* the named conditions: a hit evaluates that condition's expression — so
|
|
908
|
-
* `{
|
|
1612
|
+
* `{% if isCompany %}` and `{% if isCompany and signed %}` both work — guarded
|
|
909
1613
|
* against cycles (`resolved`) and runaway chains (`MAX_CONDITION_DEPTH`).
|
|
910
1614
|
* Otherwise the path resolves against the fill data.
|
|
911
1615
|
*/
|
|
@@ -927,12 +1631,13 @@ const makeResolver = (data, namedConditions, resolved, depth) => {
|
|
|
927
1631
|
return resolve;
|
|
928
1632
|
};
|
|
929
1633
|
/**
|
|
930
|
-
* Evaluate a template `{
|
|
1634
|
+
* Evaluate a template `{% if ... %}` condition. The expression is parsed into
|
|
931
1635
|
* the canonical `@stll/conditions` AST and evaluated by the shared evaluator.
|
|
932
1636
|
*
|
|
933
|
-
* Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`),
|
|
934
|
-
*
|
|
935
|
-
*
|
|
1637
|
+
* Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`), membership
|
|
1638
|
+
* (`"x" in path`), presence (`is defined` / `is not defined`), truthiness,
|
|
1639
|
+
* negation (`not`), logical operators (`and`, `or`), parentheses, dotted
|
|
1640
|
+
* paths, numeric underscores, and named-condition references.
|
|
936
1641
|
*
|
|
937
1642
|
* An empty or unparseable expression is `false` ("no condition" never gates a
|
|
938
1643
|
* branch true).
|
|
@@ -943,4 +1648,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
|
|
|
943
1648
|
return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
|
|
944
1649
|
};
|
|
945
1650
|
//#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,
|
|
1651
|
+
export { ARRAY_FILTER_NAMES, 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, arrayFiltersFromFieldConfig, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotKey, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, filtersFromFieldConfig, formatDate, hasBlockDirectivePattern, hasNumberingPattern, isArrayFilterName, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, legacyLoopAlias, legacyMarkerReplacement, loopPattern, markerPattern, normalizeMarkerInner, numPattern, parseCondition, placeholderPattern, qualifyLoopPath, qualifyRowScopedPath, qualifyRowScopedPlaceholder, refPattern, referencedConditionPaths, renderConditionTag, renderDeterministicFieldValue, renderFilterChain, renderForOpener, renderValueMarker, replaceOutputMarkers, resolvePath, rowScopePaths, scanInvalidMarkers, scanMarkers, serializeCondition, substitutionKey, translateLegacyExpression, unwritableFilterValues, unwritableMarkerLiteral };
|