@stll/template-conditions 0.4.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/dist/index.d.ts +182 -27
- package/dist/index.js +356 -175
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -47,8 +47,8 @@ declare const evaluateNumericExpression: (expression: string, data: Record<strin
|
|
|
47
47
|
//#region src/field-values.d.ts
|
|
48
48
|
/**
|
|
49
49
|
* Single source of truth for the DETERMINISTIC field-value transforms a
|
|
50
|
-
* template fill applies
|
|
51
|
-
*
|
|
50
|
+
* template fill applies: formula (arithmetic over the other values) and
|
|
51
|
+
* locale-aware date rendering.
|
|
52
52
|
*
|
|
53
53
|
* Both the API fill engine (apps/api/src/handlers/docx) and the web live
|
|
54
54
|
* preview (template-studio.tsx) MUST route their rendering through
|
|
@@ -83,18 +83,6 @@ type FieldDateFormat = {
|
|
|
83
83
|
* returns null.
|
|
84
84
|
*/
|
|
85
85
|
declare const formatDate: (value: string, dateFormat: FieldDateFormat) => string | null;
|
|
86
|
-
/** Minimal structural shape of one composite field part: only the `key`
|
|
87
|
-
* referenced by the join format is needed to render. Callers' richer part
|
|
88
|
-
* types (api FieldPart, web EditablePart) structurally satisfy this. */
|
|
89
|
-
type PartConfig = {
|
|
90
|
-
key: string;
|
|
91
|
-
};
|
|
92
|
-
/**
|
|
93
|
-
* Render a `{{key}}` format over part values. Markers whose key has no part
|
|
94
|
-
* value are left as-is (a visible authoring artifact, not a render error), so
|
|
95
|
-
* a partially filled composite previews exactly as the fill engine renders it.
|
|
96
|
-
*/
|
|
97
|
-
declare const renderComposite: (parts: readonly PartConfig[], format: string, partValues: Readonly<Record<string, string>>) => string;
|
|
98
86
|
/**
|
|
99
87
|
* Minimal STRUCTURAL config of a field, carrying only what the deterministic
|
|
100
88
|
* transforms read. Callers (api FieldMeta, web StudioField) structurally
|
|
@@ -104,8 +92,6 @@ declare const renderComposite: (parts: readonly PartConfig[], format: string, pa
|
|
|
104
92
|
type DeterministicFieldConfig = {
|
|
105
93
|
path: string;
|
|
106
94
|
inputType?: string | undefined;
|
|
107
|
-
parts?: readonly PartConfig[] | undefined;
|
|
108
|
-
format?: string | undefined;
|
|
109
95
|
formula?: string | undefined;
|
|
110
96
|
dateFormat?: FieldDateFormat | undefined;
|
|
111
97
|
};
|
|
@@ -115,16 +101,13 @@ type DeterministicFieldConfig = {
|
|
|
115
101
|
* no deterministic transform (a scalar, lookup, or AI field the CALLER renders
|
|
116
102
|
* itself).
|
|
117
103
|
*
|
|
118
|
-
* Dispatch order mirrors the API fill pipeline (
|
|
119
|
-
* -
|
|
120
|
-
* - else formula present → {@link evaluateNumericExpression}, stringified
|
|
104
|
+
* Dispatch order mirrors the API fill pipeline (formula → date):
|
|
105
|
+
* - formula present → {@link evaluateNumericExpression}, stringified
|
|
121
106
|
* - else date (inputType "date" + dateFormat) → {@link formatDate}
|
|
122
107
|
* - else null
|
|
123
108
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* scalar path handles it). Formula and date return null when the expression /
|
|
127
|
-
* value does not yield a value, so the caller leaves the field as-is.
|
|
109
|
+
* Formula and date return null when the expression or value does not yield a
|
|
110
|
+
* value, so the caller leaves the field as-is.
|
|
128
111
|
*/
|
|
129
112
|
declare const renderDeterministicFieldValue: (field: DeterministicFieldConfig, values: Record<string, unknown>) => string | null;
|
|
130
113
|
//#endregion
|
|
@@ -249,10 +232,16 @@ declare const normalizeMarkerInner: (inner: string) => string;
|
|
|
249
232
|
* not a nested marker. An apostrophe stays an ordinary character, so
|
|
250
233
|
* `label("Owner's")` reads as it always did.
|
|
251
234
|
*
|
|
235
|
+
* A quoted run honours `\`-escapes, the way the argument scanner reads them,
|
|
236
|
+
* so `label("she said \"yes\"")` is one span rather than three: the span and
|
|
237
|
+
* the scanner have to agree on where a literal ends or a marker the scanner
|
|
238
|
+
* would parse is one the recognizer never sees.
|
|
239
|
+
*
|
|
252
240
|
* The alternatives cannot overlap (the plain one excludes every character the
|
|
253
|
-
* quoted one may start with
|
|
254
|
-
*
|
|
255
|
-
* costs one failed pass, not a
|
|
241
|
+
* quoted one may start with, and inside a quoted run the escape branch is the
|
|
242
|
+
* only one that starts with a backslash), so the scan stays linear on
|
|
243
|
+
* adversarial input: a `{{` with no closing `}}` costs one failed pass, not a
|
|
244
|
+
* polynomial retry.
|
|
256
245
|
*/
|
|
257
246
|
declare const MARKER_OUTPUT_BODY: string;
|
|
258
247
|
/** What a tag holds between `{%` and `%}`: anything but a brace, and a `%`
|
|
@@ -389,6 +378,172 @@ declare const classifyMarkerDefect: (innerRaw: string, form?: MarkerForm) => Mar
|
|
|
389
378
|
/** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
|
|
390
379
|
declare const assertNever: (value: never) => never;
|
|
391
380
|
//#endregion
|
|
381
|
+
//#region src/loop-scope.d.ts
|
|
382
|
+
/**
|
|
383
|
+
* The path a marker names, once the loops around it are taken into account.
|
|
384
|
+
*
|
|
385
|
+
* `{% for attorney in attorneys %}{{ attorney.name }}{% endfor %}` writes one
|
|
386
|
+
* field, and the manifest calls it `attorneys.name`: the loop's alias is the
|
|
387
|
+
* body's name for the item, the array path is the manifest's. Discovery reads
|
|
388
|
+
* that mapping, and every writer that puts a configuration back into a marker
|
|
389
|
+
* has to reverse it, so the rule lives beside the grammar rather than in each
|
|
390
|
+
* of them; two readings of one loop are two different documents.
|
|
391
|
+
*/
|
|
392
|
+
/** One enclosing `{% for %}`, as the body sees it and as the manifest does. */
|
|
393
|
+
type RowScope = {
|
|
394
|
+
/** The loop variable the body addresses items through. */
|
|
395
|
+
alias: string;
|
|
396
|
+
/** The array path the opener was written with, before its own enclosing
|
|
397
|
+
* loops were applied. */
|
|
398
|
+
declaredPath: string;
|
|
399
|
+
/** The array path the manifest speaks, enclosing loops included. */
|
|
400
|
+
scopedPath: string;
|
|
401
|
+
};
|
|
402
|
+
declare const rowScopePaths: (rowScopes: readonly RowScope[]) => string[];
|
|
403
|
+
/** A path already under the innermost repeat is left alone; anything else is
|
|
404
|
+
* read as a name relative to it. */
|
|
405
|
+
declare const qualifyRowScopedPath: (path: string, rowPaths?: readonly string[]) => string;
|
|
406
|
+
/** The manifest path for a marker written inside these loops. */
|
|
407
|
+
declare const qualifyRowScopedPlaceholder: (path: string, rowScopes: readonly RowScope[]) => string;
|
|
408
|
+
/** The manifest path for an array a `{% for %}` opens inside these loops. */
|
|
409
|
+
declare const qualifyLoopPath: (declaredPath: string, rowScopes: readonly RowScope[]) => string;
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/field-config.d.ts
|
|
412
|
+
/** Where a field's value comes from, when a record supplies it. Only the shape
|
|
413
|
+
* the filters read: each caller's own union satisfies it. */
|
|
414
|
+
type MarkerFieldSource = {
|
|
415
|
+
kind: "matter";
|
|
416
|
+
field: string;
|
|
417
|
+
} | {
|
|
418
|
+
kind: "contact";
|
|
419
|
+
field: string;
|
|
420
|
+
} | {
|
|
421
|
+
kind: "firm";
|
|
422
|
+
field: string;
|
|
423
|
+
} | {
|
|
424
|
+
kind: "party";
|
|
425
|
+
role: string;
|
|
426
|
+
field: string;
|
|
427
|
+
} | {
|
|
428
|
+
kind: "attorney";
|
|
429
|
+
ref: string;
|
|
430
|
+
field: string;
|
|
431
|
+
};
|
|
432
|
+
/** The constraints a chain can express. */
|
|
433
|
+
type MarkerFieldValidation = {
|
|
434
|
+
required?: boolean | undefined;
|
|
435
|
+
pattern?: string | undefined;
|
|
436
|
+
min?: number | undefined;
|
|
437
|
+
max?: number | undefined;
|
|
438
|
+
minLength?: number | undefined;
|
|
439
|
+
maxLength?: number | undefined;
|
|
440
|
+
minItems?: number | undefined;
|
|
441
|
+
maxItems?: number | undefined;
|
|
442
|
+
};
|
|
443
|
+
/** A registry lookup and the renderings of its one resolved hit. */
|
|
444
|
+
type MarkerFieldLookup = {
|
|
445
|
+
registry: string;
|
|
446
|
+
formats: readonly {
|
|
447
|
+
key: string;
|
|
448
|
+
template: string;
|
|
449
|
+
}[];
|
|
450
|
+
};
|
|
451
|
+
/** The input controls a marker can name. */
|
|
452
|
+
type MarkerFieldInputType = "text" | "number" | "boolean" | "date" | "select";
|
|
453
|
+
/**
|
|
454
|
+
* Everything about a field that a marker's filter chain can carry. A caller's
|
|
455
|
+
* own field type may hold more (a derived condition AST, a discovery count);
|
|
456
|
+
* those keys are simply not read here.
|
|
457
|
+
*/
|
|
458
|
+
type MarkerFieldConfig = {
|
|
459
|
+
path: string;
|
|
460
|
+
label?: string | undefined;
|
|
461
|
+
hint?: string | undefined;
|
|
462
|
+
inputType?: MarkerFieldInputType | undefined;
|
|
463
|
+
options?: readonly string[] | undefined;
|
|
464
|
+
optionsFrom?: string | undefined;
|
|
465
|
+
validation?: MarkerFieldValidation | undefined;
|
|
466
|
+
required?: boolean | undefined;
|
|
467
|
+
aiPrompt?: string | undefined;
|
|
468
|
+
aiAdapt?: boolean | undefined;
|
|
469
|
+
aiSeesDocument?: boolean | undefined;
|
|
470
|
+
lookup?: MarkerFieldLookup | undefined;
|
|
471
|
+
source?: MarkerFieldSource | undefined;
|
|
472
|
+
formula?: string | undefined;
|
|
473
|
+
condition?: string | undefined;
|
|
474
|
+
dateFormat?: FieldDateFormat | undefined;
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* The filters that describe a REPEAT rather than a value: how many rows it
|
|
478
|
+
* takes and what to call it. These are the ones a `{% for %}` tag may carry,
|
|
479
|
+
* since the loop path names the array, not one of its values.
|
|
480
|
+
*/
|
|
481
|
+
declare const ARRAY_FILTER_NAMES: readonly ["label", "hint", "required", "min_items", "max_items"];
|
|
482
|
+
declare const isArrayFilterName: (name: FilterName) => boolean;
|
|
483
|
+
/**
|
|
484
|
+
* The filter chain that declares this field in a document, in the catalogue's
|
|
485
|
+
* own order so a chain the writer produces and one an author wrote read the
|
|
486
|
+
* same.
|
|
487
|
+
*/
|
|
488
|
+
declare const filtersFromFieldConfig: (field: MarkerFieldConfig) => FilterCall[];
|
|
489
|
+
/**
|
|
490
|
+
* The chain a `{% for %}` opener carries for this array: the repeat's own
|
|
491
|
+
* filters and nothing else, derived from {@link ARRAY_FILTER_NAMES}, so a
|
|
492
|
+
* value filter can never be written where the reader refuses it.
|
|
493
|
+
*/
|
|
494
|
+
declare const arrayFiltersFromFieldConfig: (field: MarkerFieldConfig) => FilterCall[];
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/render.d.ts
|
|
497
|
+
/** Why a value has no spelling the scanner reads back as itself. */
|
|
498
|
+
type UnwritableReason = "tag-delimiter" | "number-spelling";
|
|
499
|
+
/** The reason this literal cannot be written in a marker of this form, or
|
|
500
|
+
* `null` when it can. A boolean always can; a string can in a `{{ }}` marker,
|
|
501
|
+
* because the writer quotes and escapes it. */
|
|
502
|
+
declare const unwritableMarkerLiteral: (value: MarkerLiteral, form?: MarkerForm) => UnwritableReason | null;
|
|
503
|
+
/** Every value in a chain the grammar has no spelling for, with the filter it
|
|
504
|
+
* was written on and why, so a caller can refuse a configuration before it
|
|
505
|
+
* rewrites a document into something the scanner would read differently. A
|
|
506
|
+
* repeat's chain goes in a tag, so pass `"statement"` for one. */
|
|
507
|
+
declare const unwritableFilterValues: (filters: readonly FilterCall[], form?: MarkerForm) => {
|
|
508
|
+
filter: string;
|
|
509
|
+
value: MarkerLiteral;
|
|
510
|
+
reason: UnwritableReason;
|
|
511
|
+
}[];
|
|
512
|
+
/**
|
|
513
|
+
* One filter chain as marker text, without the leading pipe: `label("A") |
|
|
514
|
+
* required`. An empty chain renders the empty string, which is what a marker
|
|
515
|
+
* that configures nothing carries.
|
|
516
|
+
*/
|
|
517
|
+
declare const renderFilterChain: (filters: readonly FilterCall[]) => string;
|
|
518
|
+
/** One value marker: `{{ path }}` or `{{ path | filters }}`. */
|
|
519
|
+
declare const renderValueMarker: (path: string, filters: readonly FilterCall[]) => string;
|
|
520
|
+
type ConditionTagOptions = {
|
|
521
|
+
/** Which branch opener this is; `elif` keeps its place in the chain. */
|
|
522
|
+
kind: "if" | "elif";
|
|
523
|
+
/** The `{% if %}` expression, already serialized. */
|
|
524
|
+
expression: string;
|
|
525
|
+
/** The placement the tag already carried. */
|
|
526
|
+
prefix: MarkerPrefix;
|
|
527
|
+
};
|
|
528
|
+
/**
|
|
529
|
+
* One `{% if %}` / `{% elif %}` opener carrying its expression.
|
|
530
|
+
*
|
|
531
|
+
* A rule that decides whether a block shows belongs in the tag that shows it:
|
|
532
|
+
* the tag is the only place a document has for it, so a builder's rule is
|
|
533
|
+
* serialized here rather than stored beside the document under a name.
|
|
534
|
+
*/
|
|
535
|
+
declare const renderConditionTag: ({ expression, kind, prefix }: ConditionTagOptions) => string;
|
|
536
|
+
type ForOpenerOptions = {
|
|
537
|
+
alias: string;
|
|
538
|
+
path: string;
|
|
539
|
+
filters: readonly FilterCall[];
|
|
540
|
+
/** The placement the opener already carried, so rewriting its filters does
|
|
541
|
+
* not move a row-form loop off its table row. */
|
|
542
|
+
prefix: MarkerPrefix;
|
|
543
|
+
};
|
|
544
|
+
/** One `{% for %}` opener, keeping its placement prefix. */
|
|
545
|
+
declare const renderForOpener: ({ alias, filters, path, prefix }: ForOpenerOptions) => string;
|
|
546
|
+
//#endregion
|
|
392
547
|
//#region src/row-blocks.d.ts
|
|
393
548
|
/** One end of a row block, addressed within the row that was scanned. */
|
|
394
549
|
type RowBlockMarker = {
|
|
@@ -451,4 +606,4 @@ declare const MAX_CONDITION_DEPTH = 50;
|
|
|
451
606
|
*/
|
|
452
607
|
declare const evaluateCondition: (expression: string, data: Record<string, unknown>, namedConditions?: readonly NamedCondition[], _resolved?: ReadonlySet<string>, _depth?: number) => boolean;
|
|
453
608
|
//#endregion
|
|
454
|
-
export { BLOCK_DIRECTIVE_KINDS, type BlockDirectiveKind, type CompareNode, type CompareOp, type Condition, type ConditionNode, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, type DateFormatStyle, type DeterministicFieldConfig, type DirectiveKind, FILTER_NAMES, type FieldDateFormat, type FilterArgument, type FilterCall, type FilterName, type GroupNode, type InvalidMarker, LOOP_PROPERTIES, type LoopProperty, MARKER_DEFECT_KINDS, MARKER_OUTPUT_BODY, MARKER_STATEMENT_BODY, MAX_CONDITION_DEPTH, type MarkerDefect, type MarkerDefectKind, type MarkerForm, type MarkerLiteral, type MarkerMeta, type MarkerPrefix, NUMERIC_FUNCTION_NAMES, NamedCondition, type NumericFunctionName, type Operand, type
|
|
609
|
+
export { ARRAY_FILTER_NAMES, BLOCK_DIRECTIVE_KINDS, type BlockDirectiveKind, type CompareNode, type CompareOp, type Condition, type ConditionNode, type ConditionTagOptions, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, type DateFormatStyle, type DeterministicFieldConfig, type DirectiveKind, FILTER_NAMES, type FieldDateFormat, type FilterArgument, type FilterCall, type FilterName, type ForOpenerOptions, type GroupNode, type InvalidMarker, LOOP_PROPERTIES, type LoopProperty, MARKER_DEFECT_KINDS, MARKER_OUTPUT_BODY, MARKER_STATEMENT_BODY, MAX_CONDITION_DEPTH, type MarkerDefect, type MarkerDefectKind, type MarkerFieldConfig, type MarkerFieldInputType, type MarkerFieldLookup, type MarkerFieldSource, type MarkerFieldValidation, type MarkerForm, type MarkerLiteral, type MarkerMeta, type MarkerPrefix, NUMERIC_FUNCTION_NAMES, NamedCondition, type NumericFunctionName, type Operand, type PredicateNode, type PredicateOp, type RowBlockMarker, type RowBlockPair, type RowScope, type ScannedMarker, type UnwritableReason, 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 };
|
package/dist/index.js
CHANGED
|
@@ -454,6 +454,164 @@ const referencedConditionPaths = (expression) => {
|
|
|
454
454
|
return node === null ? null : nodePaths(node);
|
|
455
455
|
};
|
|
456
456
|
//#endregion
|
|
457
|
+
//#region src/field-values.ts
|
|
458
|
+
/**
|
|
459
|
+
* Single source of truth for the DETERMINISTIC field-value transforms a
|
|
460
|
+
* template fill applies: formula (arithmetic over the other values) and
|
|
461
|
+
* locale-aware date rendering.
|
|
462
|
+
*
|
|
463
|
+
* Both the API fill engine (apps/api/src/handlers/docx) and the web live
|
|
464
|
+
* preview (template-studio.tsx) MUST route their rendering through
|
|
465
|
+
* {@link renderDeterministicFieldValue} so the preview and the generated
|
|
466
|
+
* document produce byte-identical strings. The api/field-value-parity.test.ts
|
|
467
|
+
* asserts this; reintroducing a bespoke transform on either side fails CI.
|
|
468
|
+
*
|
|
469
|
+
* Out of scope (non-deterministic / server-only, handled by callers):
|
|
470
|
+
* - registry lookup (needs a network hit; the preview overlays it async)
|
|
471
|
+
* - AI drafting / adaptation
|
|
472
|
+
*
|
|
473
|
+
* Pure: no IO, no model/provider dependency. The config types here are minimal
|
|
474
|
+
* STRUCTURAL shapes so the package stays free of api/web imports; callers pass
|
|
475
|
+
* their own field objects, which structurally satisfy these.
|
|
476
|
+
*/
|
|
477
|
+
const DATE_FORMAT_STYLES = [
|
|
478
|
+
"long",
|
|
479
|
+
"medium",
|
|
480
|
+
"short",
|
|
481
|
+
"iso"
|
|
482
|
+
];
|
|
483
|
+
/** Stable exemplar for date-format configuration previews. */
|
|
484
|
+
const DATE_FORMAT_EXAMPLE_ISO = "2028-06-13";
|
|
485
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
|
|
486
|
+
const STYLE_OPTIONS = {
|
|
487
|
+
long: { dateStyle: "long" },
|
|
488
|
+
medium: { dateStyle: "medium" },
|
|
489
|
+
short: { dateStyle: "short" }
|
|
490
|
+
};
|
|
491
|
+
const dateFormatters = /* @__PURE__ */ new Map();
|
|
492
|
+
/** `Intl.DateTimeFormat` for a locale + style pair, cached per pair so it
|
|
493
|
+
* isn't rebuilt on every {@link formatDate} call. */
|
|
494
|
+
const getDateFormatter = (locale, style) => {
|
|
495
|
+
const key = `${locale}:${style}`;
|
|
496
|
+
const cached = dateFormatters.get(key);
|
|
497
|
+
if (cached) return cached;
|
|
498
|
+
const formatter = new Intl.DateTimeFormat(locale, {
|
|
499
|
+
...STYLE_OPTIONS[style],
|
|
500
|
+
timeZone: "UTC"
|
|
501
|
+
});
|
|
502
|
+
dateFormatters.set(key, formatter);
|
|
503
|
+
return formatter;
|
|
504
|
+
};
|
|
505
|
+
/**
|
|
506
|
+
* Parse a strict YYYY-MM-DD calendar date; null when malformed or not a real
|
|
507
|
+
* date. UTC-anchored so the rendered day never shifts with the timezone.
|
|
508
|
+
* `Date` rolls out-of-range components over (2028-02-30 → March 1), so the
|
|
509
|
+
* round-trip comparison catches non-existent dates.
|
|
510
|
+
*/
|
|
511
|
+
const parseIsoDate = (value) => {
|
|
512
|
+
if (!ISO_DATE_PATTERN.test(value)) return null;
|
|
513
|
+
const date = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
|
|
514
|
+
if (Number.isNaN(date.getTime()) || !date.toISOString().startsWith(value)) return null;
|
|
515
|
+
return date;
|
|
516
|
+
};
|
|
517
|
+
/**
|
|
518
|
+
* Format an ISO date (YYYY-MM-DD) per the field's locale + style via
|
|
519
|
+
* `Intl.DateTimeFormat` — ICU produces the correct localized (and, in date
|
|
520
|
+
* contexts, correctly inflected) month names, e.g. cs long → "13. června
|
|
521
|
+
* 2028". The "iso" style returns the value unchanged; an invalid calendar date
|
|
522
|
+
* returns null.
|
|
523
|
+
*/
|
|
524
|
+
const formatDate = (value, dateFormat) => {
|
|
525
|
+
const date = parseIsoDate(value);
|
|
526
|
+
if (date === null) return null;
|
|
527
|
+
if (dateFormat.style === "iso") return value;
|
|
528
|
+
return getDateFormatter(dateFormat.locale, dateFormat.style).format(date);
|
|
529
|
+
};
|
|
530
|
+
/**
|
|
531
|
+
* THE deterministic field-value dispatcher: returns the string the fill engine
|
|
532
|
+
* writes for `field` given the submitted `values`, or null when the field has
|
|
533
|
+
* no deterministic transform (a scalar, lookup, or AI field the CALLER renders
|
|
534
|
+
* itself).
|
|
535
|
+
*
|
|
536
|
+
* Dispatch order mirrors the API fill pipeline (formula → date):
|
|
537
|
+
* - formula present → {@link evaluateNumericExpression}, stringified
|
|
538
|
+
* - else date (inputType "date" + dateFormat) → {@link formatDate}
|
|
539
|
+
* - else null
|
|
540
|
+
*
|
|
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.
|
|
543
|
+
*/
|
|
544
|
+
const renderDeterministicFieldValue = (field, values) => {
|
|
545
|
+
if (field.formula !== void 0) {
|
|
546
|
+
const result = evaluateNumericExpression(field.formula, values);
|
|
547
|
+
return result === void 0 ? null : String(result);
|
|
548
|
+
}
|
|
549
|
+
if (field.inputType === "date" && field.dateFormat !== void 0) {
|
|
550
|
+
const raw = resolvePath(field.path, values);
|
|
551
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
552
|
+
return formatDate(raw, field.dateFormat);
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
};
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region src/condition-builder.ts
|
|
558
|
+
const COMPARE_OP_TO_SYMBOL = {
|
|
559
|
+
eq: "==",
|
|
560
|
+
neq: "!=",
|
|
561
|
+
gt: ">",
|
|
562
|
+
lt: "<",
|
|
563
|
+
gte: ">=",
|
|
564
|
+
lte: "<="
|
|
565
|
+
};
|
|
566
|
+
/** Render a literal: strings are quoted/escaped, booleans and numbers are
|
|
567
|
+
* emitted bare (matching the parser's literal handling); an array literal
|
|
568
|
+
* joins on `,` (rare; the template builder edits scalar rules). */
|
|
569
|
+
const serializeLiteral = (value) => {
|
|
570
|
+
if (typeof value === "string") return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
|
|
571
|
+
if (Array.isArray(value)) return `"${value.join(",")}"`;
|
|
572
|
+
return String(value);
|
|
573
|
+
};
|
|
574
|
+
const serializeOperand = (operand) => {
|
|
575
|
+
if (operand.type === "literal") return serializeLiteral(operand.value);
|
|
576
|
+
if (operand.type === "path") return operand.path;
|
|
577
|
+
if (operand.type === "formula") panic("serializeCondition: formula operand requires AST storage");
|
|
578
|
+
return "";
|
|
579
|
+
};
|
|
580
|
+
const serializeCompare = (node) => `${serializeOperand(node.left)} ${COMPARE_OP_TO_SYMBOL[node.op]} ${serializeOperand(node.right)}`;
|
|
581
|
+
const serializePredicate = (node) => {
|
|
582
|
+
const operand = serializeOperand(node.operand);
|
|
583
|
+
if (node.op === "contains") {
|
|
584
|
+
const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
|
|
585
|
+
return `${serializeLiteral(value)} in ${operand}`;
|
|
586
|
+
}
|
|
587
|
+
if (node.op === "is_not_empty") return `${operand} is defined`;
|
|
588
|
+
if (node.op === "is_empty") return `${operand} is not defined`;
|
|
589
|
+
return operand;
|
|
590
|
+
};
|
|
591
|
+
const serializeGroup = (group, top) => {
|
|
592
|
+
const parts = [];
|
|
593
|
+
for (const child of group.children) {
|
|
594
|
+
const part = serializeNode(child, false);
|
|
595
|
+
if (part.length > 0) parts.push(part);
|
|
596
|
+
}
|
|
597
|
+
if (parts.length === 0) return "";
|
|
598
|
+
const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
|
|
599
|
+
if (group.negated) return `not (${body})`;
|
|
600
|
+
if (parts.length === 1) return body;
|
|
601
|
+
return top ? body : `(${body})`;
|
|
602
|
+
};
|
|
603
|
+
const serializeNode = (node, top) => {
|
|
604
|
+
if (node.type === "compare") return serializeCompare(node);
|
|
605
|
+
if (node.type === "predicate") return serializePredicate(node);
|
|
606
|
+
return serializeGroup(node, top);
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* Serialize a condition tree into an expression for `evaluateCondition`.
|
|
610
|
+
* Returns an empty string for an empty tree (caller treats that as "no
|
|
611
|
+
* condition" / always-visible).
|
|
612
|
+
*/
|
|
613
|
+
const serializeCondition = (node) => serializeNode(node, true);
|
|
614
|
+
//#endregion
|
|
457
615
|
//#region src/markers.ts
|
|
458
616
|
/**
|
|
459
617
|
* Canonical grammar for the docxtpl dialect of Jinja — the single source of
|
|
@@ -560,12 +718,18 @@ const normalizeMarkerInner = (inner) => inner.replace(SMART_DOUBLE_QUOTES, "\"")
|
|
|
560
718
|
* not a nested marker. An apostrophe stays an ordinary character, so
|
|
561
719
|
* `label("Owner's")` reads as it always did.
|
|
562
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
|
+
*
|
|
563
726
|
* The alternatives cannot overlap (the plain one excludes every character the
|
|
564
|
-
* quoted one may start with
|
|
565
|
-
*
|
|
566
|
-
* costs one failed pass, not a
|
|
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.
|
|
567
731
|
*/
|
|
568
|
-
const MARKER_OUTPUT_BODY = String.raw`(?:[^{}"“”„«»]|["“„«][^"
|
|
732
|
+
const MARKER_OUTPUT_BODY = String.raw`(?:[^{}"“”„«»]|["“„«](?:\\[\s\S]|[^"”»\\])*["”»])*`;
|
|
569
733
|
/** What a tag holds between `{%` and `%}`: anything but a brace, and a `%`
|
|
570
734
|
* only where it does not close the tag. */
|
|
571
735
|
const MARKER_STATEMENT_BODY = String.raw`(?:[^{}%]|%(?!\}))*`;
|
|
@@ -1093,195 +1257,212 @@ const classifyMarkerDefect = (innerRaw, form = "output") => {
|
|
|
1093
1257
|
/** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
|
|
1094
1258
|
const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
|
|
1095
1259
|
//#endregion
|
|
1096
|
-
//#region src/
|
|
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
|
|
1097
1282
|
/**
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
* Both the API fill engine (apps/api/src/handlers/docx) and the web live
|
|
1103
|
-
* preview (template-studio.tsx) MUST route their rendering through
|
|
1104
|
-
* {@link renderDeterministicFieldValue} so the preview and the generated
|
|
1105
|
-
* document produce byte-identical strings. The api/field-value-parity.test.ts
|
|
1106
|
-
* asserts this; reintroducing a bespoke transform on either side fails CI.
|
|
1107
|
-
*
|
|
1108
|
-
* Out of scope (non-deterministic / server-only, handled by callers):
|
|
1109
|
-
* - registry lookup (needs a network hit; the preview overlays it async)
|
|
1110
|
-
* - AI drafting / adaptation
|
|
1111
|
-
*
|
|
1112
|
-
* Pure: no IO, no model/provider dependency. The config types here are minimal
|
|
1113
|
-
* STRUCTURAL shapes so the package stays free of api/web imports; callers pass
|
|
1114
|
-
* their own field objects, which structurally satisfy these.
|
|
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.
|
|
1115
1286
|
*/
|
|
1116
|
-
const
|
|
1117
|
-
"
|
|
1118
|
-
"
|
|
1119
|
-
"
|
|
1120
|
-
"
|
|
1287
|
+
const ARRAY_FILTER_NAMES = [
|
|
1288
|
+
"label",
|
|
1289
|
+
"hint",
|
|
1290
|
+
"required",
|
|
1291
|
+
"min_items",
|
|
1292
|
+
"max_items"
|
|
1121
1293
|
];
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
+
}
|
|
1129
1335
|
};
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
}
|
|
1141
|
-
dateFormatters.set(key, formatter);
|
|
1142
|
-
return formatter;
|
|
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
|
+
}
|
|
1143
1347
|
};
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
const
|
|
1151
|
-
if (
|
|
1152
|
-
const
|
|
1153
|
-
if (
|
|
1154
|
-
|
|
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;
|
|
1155
1363
|
};
|
|
1156
1364
|
/**
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
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.
|
|
1162
1372
|
*/
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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))]
|
|
1168
1390
|
};
|
|
1169
1391
|
/**
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
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.
|
|
1173
1395
|
*/
|
|
1174
|
-
const
|
|
1175
|
-
const key = inner.trim();
|
|
1176
|
-
if (!parts.some((part) => part.key === key)) return raw;
|
|
1177
|
-
return partValues[key] ?? raw;
|
|
1178
|
-
});
|
|
1179
|
-
/** A plain object of part values: a non-null, non-array object. The api
|
|
1180
|
-
* composite step accepts exactly this shape (an array or primitive is not a
|
|
1181
|
-
* part-values object), so the dispatcher mirrors it. */
|
|
1182
|
-
const isPartValuesObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1183
|
-
/** Build the `{{key}} → value` map for a composite field from its raw object
|
|
1184
|
-
* value, keeping only string part values for declared part keys. */
|
|
1185
|
-
const compositePartValues = (parts, raw) => {
|
|
1186
|
-
const partValues = {};
|
|
1187
|
-
for (const part of parts) {
|
|
1188
|
-
const value = raw[part.key];
|
|
1189
|
-
if (typeof value === "string") partValues[part.key] = value;
|
|
1190
|
-
}
|
|
1191
|
-
return partValues;
|
|
1192
|
-
};
|
|
1396
|
+
const filtersFromFieldConfig = (field) => Object.values(MARKER_FIELD_FILTERS).flatMap((write) => write(field));
|
|
1193
1397
|
/**
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* itself).
|
|
1198
|
-
*
|
|
1199
|
-
* Dispatch order mirrors the API fill pipeline (composite → formula → date):
|
|
1200
|
-
* - composite (parts + format present) → {@link renderComposite}
|
|
1201
|
-
* - else formula present → {@link evaluateNumericExpression}, stringified
|
|
1202
|
-
* - else date (inputType "date" + dateFormat) → {@link formatDate}
|
|
1203
|
-
* - else null
|
|
1204
|
-
*
|
|
1205
|
-
* Composite returns null when its value is not an object of part values (a
|
|
1206
|
-
* plain string passes through unchanged on the api side, so the caller's
|
|
1207
|
-
* scalar path handles it). Formula and date return null when the expression /
|
|
1208
|
-
* value does not yield a value, so the caller leaves the field as-is.
|
|
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.
|
|
1209
1401
|
*/
|
|
1210
|
-
const
|
|
1211
|
-
if (field.parts !== void 0 && field.format !== void 0) {
|
|
1212
|
-
const raw = resolvePath(field.path, values);
|
|
1213
|
-
if (!isPartValuesObject(raw)) return null;
|
|
1214
|
-
return renderComposite(field.parts, field.format, compositePartValues(field.parts, raw));
|
|
1215
|
-
}
|
|
1216
|
-
if (field.formula !== void 0) {
|
|
1217
|
-
const result = evaluateNumericExpression(field.formula, values);
|
|
1218
|
-
return result === void 0 ? null : String(result);
|
|
1219
|
-
}
|
|
1220
|
-
if (field.inputType === "date" && field.dateFormat !== void 0) {
|
|
1221
|
-
const raw = resolvePath(field.path, values);
|
|
1222
|
-
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
1223
|
-
return formatDate(raw, field.dateFormat);
|
|
1224
|
-
}
|
|
1225
|
-
return null;
|
|
1226
|
-
};
|
|
1402
|
+
const arrayFiltersFromFieldConfig = (field) => filtersFromFieldConfig(field).filter(({ name }) => isArrayFilterName(name));
|
|
1227
1403
|
//#endregion
|
|
1228
|
-
//#region src/
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
if (typeof value === "string") return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
|
|
1242
|
-
if (Array.isArray(value)) return `"${value.join(",")}"`;
|
|
1243
|
-
return String(value);
|
|
1244
|
-
};
|
|
1245
|
-
const serializeOperand = (operand) => {
|
|
1246
|
-
if (operand.type === "literal") return serializeLiteral(operand.value);
|
|
1247
|
-
if (operand.type === "path") return operand.path;
|
|
1248
|
-
if (operand.type === "formula") panic("serializeCondition: formula operand requires AST storage");
|
|
1249
|
-
return "";
|
|
1250
|
-
};
|
|
1251
|
-
const serializeCompare = (node) => `${serializeOperand(node.left)} ${COMPARE_OP_TO_SYMBOL[node.op]} ${serializeOperand(node.right)}`;
|
|
1252
|
-
const serializePredicate = (node) => {
|
|
1253
|
-
const operand = serializeOperand(node.operand);
|
|
1254
|
-
if (node.op === "contains") {
|
|
1255
|
-
const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
|
|
1256
|
-
return `${serializeLiteral(value)} in ${operand}`;
|
|
1257
|
-
}
|
|
1258
|
-
if (node.op === "is_not_empty") return `${operand} is defined`;
|
|
1259
|
-
if (node.op === "is_empty") return `${operand} is not defined`;
|
|
1260
|
-
return operand;
|
|
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;
|
|
1261
1417
|
};
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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} }}`;
|
|
1273
1445
|
};
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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"
|
|
1278
1451
|
};
|
|
1279
1452
|
/**
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
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.
|
|
1283
1458
|
*/
|
|
1284
|
-
const
|
|
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
|
+
};
|
|
1285
1466
|
//#endregion
|
|
1286
1467
|
//#region src/row-blocks.ts
|
|
1287
1468
|
/**
|
|
@@ -1467,4 +1648,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
|
|
|
1467
1648
|
return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
|
|
1468
1649
|
};
|
|
1469
1650
|
//#endregion
|
|
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,
|
|
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 };
|
package/package.json
CHANGED