@stll/template-conditions 0.2.1 → 0.3.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 CHANGED
@@ -258,9 +258,53 @@ type InvalidMarker = {
258
258
  * complement of {@link scanMarkers}: a span is in one list or the other.
259
259
  */
260
260
  declare const scanInvalidMarkers: (text: string) => InvalidMarker[];
261
+ /**
262
+ * Why a `{{...}}` span misses the grammar in a way that names the authoring
263
+ * mistake rather than merely failing to classify. Consumers derive their own
264
+ * warning codes from {@link MARKER_DEFECT_KINDS} so the two never drift.
265
+ */
266
+ type MarkerDefectKind = "unknown_directive" | "bracket_index";
267
+ declare const MARKER_DEFECT_KINDS: readonly ["unknown_directive", "bracket_index"];
268
+ /**
269
+ * Name the authoring mistake behind one marker's inner text, or `null` when the
270
+ * text is a recognized directive ({@link classifyMarker} accepts it) or an
271
+ * unrecognizable span with no specific diagnosis (`{{my field}}`). The
272
+ * recognized-directive check is what makes `DIRECTIVE_KINDS` the single
273
+ * authority on which `{{#...}}` tokens exist: adding a directive to the grammar
274
+ * stops it being reported here, with no list to update.
275
+ */
276
+ declare const classifyMarkerDefect: (innerRaw: string) => MarkerDefectKind | null;
261
277
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
262
278
  declare const assertNever: (value: never) => never;
263
279
  //#endregion
280
+ //#region src/row-blocks.d.ts
281
+ /** One end of a row block, addressed within the row that was scanned. */
282
+ type RowBlockMarker = {
283
+ /** Index into the row's cells. */
284
+ cellIndex: number;
285
+ /** Index into that cell's paragraphs. */
286
+ paragraphIndex: number;
287
+ marker: ScannedMarker;
288
+ };
289
+ type RowBlockPair = {
290
+ open: RowBlockMarker;
291
+ close: RowBlockMarker;
292
+ };
293
+ /**
294
+ * The one row block a table row declares in the lenient form, or `null`.
295
+ *
296
+ * `cells` is the row's cells in document order, each as its paragraph texts in
297
+ * document order.
298
+ *
299
+ * A row qualifies when exactly two block markers reach outside their own
300
+ * paragraph, they are a matching opener/closer pair, the closer sits in a LATER
301
+ * cell than the opener, the opener prefixes its cell and the closer suffixes
302
+ * its cell. Everything else — one half of a pair, two nested pairs, a stray
303
+ * `{{#else}}`, a pair wrapping the paragraphs of a single cell — is left to the
304
+ * engine's existing structure errors rather than guessed at.
305
+ */
306
+ declare const detectRowBlockPair: (cells: readonly (readonly string[])[]) => RowBlockPair | null;
307
+ //#endregion
264
308
  //#region src/index.d.ts
265
309
  type NamedCondition = {
266
310
  name: string;
@@ -294,4 +338,4 @@ declare const MAX_CONDITION_DEPTH = 50;
294
338
  */
295
339
  declare const evaluateCondition: (expression: string, data: Record<string, unknown>, namedConditions?: readonly NamedCondition[], _resolved?: ReadonlySet<string>, _depth?: number) => boolean;
296
340
  //#endregion
297
- 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, type FieldDateFormat, type GroupNode, type InvalidMarker, MAX_CONDITION_DEPTH, type MarkerMeta, NUMERIC_FUNCTION_NAMES, NamedCondition, type NumericFunctionName, type Operand, type PartConfig, type PredicateNode, type PredicateOp, type ScannedMarker, assertNever, blockDirectiveLinePattern, classifyMarker, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, countPattern, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, indexPattern, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, markerPattern, numPattern, parseCondition, placeholderPattern, refPattern, renderComposite, renderDeterministicFieldValue, resolvePath, scanInvalidMarkers, scanMarkers, serializeCondition };
341
+ 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, type FieldDateFormat, type GroupNode, type InvalidMarker, MARKER_DEFECT_KINDS, MAX_CONDITION_DEPTH, type MarkerDefectKind, type MarkerMeta, NUMERIC_FUNCTION_NAMES, NamedCondition, type NumericFunctionName, type Operand, type PartConfig, type PredicateNode, type PredicateOp, type RowBlockMarker, type RowBlockPair, type ScannedMarker, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, countPattern, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, indexPattern, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, markerPattern, numPattern, parseCondition, placeholderPattern, refPattern, renderComposite, renderDeterministicFieldValue, resolvePath, scanInvalidMarkers, scanMarkers, serializeCondition };
package/dist/index.js CHANGED
@@ -6,7 +6,14 @@ import { panic } from "better-result";
6
6
  * the condition engine (index.ts) and the arithmetic evaluator (compute.ts) can
7
7
  * import it without a dependency cycle.
8
8
  */
9
- /** Narrow `unknown` to a string-keyed record. */
9
+ /**
10
+ * Narrow `unknown` to something a path segment can be read from.
11
+ *
12
+ * Unlike the same-named predicates that screen JSON payload shapes, this one
13
+ * accepts arrays on purpose: a dotted path segment may be an array index
14
+ * (`parties.1.name`), and rejecting arrays would silently resolve those to
15
+ * `undefined`.
16
+ */
10
17
  const isRecord = (v) => typeof v === "object" && v !== null;
11
18
  /** Resolve a dotted path like `company.name` against data. */
12
19
  const resolvePath = (path, data) => {
@@ -101,7 +108,7 @@ const callFunction = (name, args) => {
101
108
  const factor = 10 ** Math.trunc(digits);
102
109
  return Math.round(value * factor) / factor;
103
110
  }
104
- default: return name;
111
+ default: return panic(`Unhandled name: ${String(name)}`);
105
112
  }
106
113
  };
107
114
  const evaluateNumericExpression = (expression, data) => {
@@ -542,6 +549,26 @@ const scanInvalidMarkers = (text) => {
542
549
  }
543
550
  return out;
544
551
  };
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
+ /**
558
+ * Name the authoring mistake behind one marker's inner text, or `null` when the
559
+ * text is a recognized directive ({@link classifyMarker} accepts it) or an
560
+ * unrecognizable span with no specific diagnosis (`{{my field}}`). The
561
+ * recognized-directive check is what makes `DIRECTIVE_KINDS` the single
562
+ * authority on which `{{#...}}` tokens exist: adding a directive to the grammar
563
+ * stops it being reported here, with no list to update.
564
+ */
565
+ const classifyMarkerDefect = (innerRaw) => {
566
+ const inner = innerRaw.trim();
567
+ if (classifyMarker(inner) !== null) return null;
568
+ if (DIRECTIVE_SHAPE_RE.test(inner)) return "unknown_directive";
569
+ if (BRACKET_INDEX_RE.test(inner)) return "bracket_index";
570
+ return null;
571
+ };
545
572
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
546
573
  const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
547
574
  //#endregion
@@ -733,6 +760,116 @@ const serializeNode = (node, top) => {
733
760
  */
734
761
  const serializeCondition = (node) => serializeNode(node, true);
735
762
  //#endregion
763
+ //#region src/row-blocks.ts
764
+ /**
765
+ * Row-form block markers: the lenient placement authoring agents actually
766
+ * write for a "one row per item" table.
767
+ *
768
+ * The canonical placement gives a block marker its own paragraph. In a table
769
+ * that costs an extra empty-looking paragraph in the first and last cell, so
770
+ * an author instead types the opener in front of the first cell's text and the
771
+ * closer behind the last cell's text:
772
+ *
773
+ * | {{#each deliverables}}{{deliverables.item}} | {{deliverables.fee}}{{/each}} |
774
+ *
775
+ * Both placements mean the same thing — the `w:tr` is the unit — so this
776
+ * module recognizes the row form purely from text, and the consumer rewrites
777
+ * it into the canonical form before any engine runs.
778
+ *
779
+ * Text-only and structure-free on purpose: the fill/discovery pipeline feeds
780
+ * it OOXML run text, the authoring eval feeds it the cells a model produced,
781
+ * and neither can drift from the other's idea of what a row block is.
782
+ */
783
+ const CLOSER_OF = {
784
+ each: "endeach",
785
+ if: "endif"
786
+ };
787
+ const isOpenKind = (kind) => kind === "each" || kind === "if";
788
+ /**
789
+ * 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{{#if x}} and
791
+ * spouse{{/if}}`), which the inline engine already owns; only what is left
792
+ * over can reach across cells.
793
+ *
794
+ * A branch marker (`{{#elseif}}`, `{{#else}}`) counts as left over unless its
795
+ * `{{#if}}` also closes in this paragraph. Only the opener and the closer of a
796
+ * row block are hoisted, so a branch marker stranded between them would stay
797
+ * buried in the cell text: a false condition would drop the whole row and the
798
+ * branch that should have rendered with it, and a true one would leave the
799
+ * marker behind as an inline orphan. Counting it here refuses the placement
800
+ * instead.
801
+ *
802
+ * A paragraph that is nothing but a directive is the canonical form and
803
+ * belongs to the block engine, so it contributes nothing here.
804
+ */
805
+ const danglingBlockMarkers = (text) => {
806
+ if (blockDirectiveLinePattern().test(text)) return [];
807
+ const open = [];
808
+ const dangling = [];
809
+ for (const marker of scanMarkers(text)) {
810
+ const { kind } = marker.meta;
811
+ if (isOpenKind(kind)) {
812
+ open.push({
813
+ kind,
814
+ marker,
815
+ branches: []
816
+ });
817
+ continue;
818
+ }
819
+ if (kind === "endeach" || kind === "endif") {
820
+ const innermost = open.at(-1);
821
+ if (innermost && CLOSER_OF[innermost.kind] === kind) open.pop();
822
+ else dangling.push(marker);
823
+ continue;
824
+ }
825
+ if (kind === "elseif" || kind === "else") {
826
+ const innermost = open.at(-1);
827
+ if (innermost?.kind === "if") innermost.branches.push(marker);
828
+ else dangling.push(marker);
829
+ }
830
+ }
831
+ for (const { branches, marker } of open) dangling.push(marker, ...branches);
832
+ return dangling.toSorted((a, b) => a.start - b.start);
833
+ };
834
+ const firstContentParagraph = (paragraphs) => paragraphs.findIndex((text) => text.trim() !== "");
835
+ const lastContentParagraph = (paragraphs) => paragraphs.findLastIndex((text) => text.trim() !== "");
836
+ /** The marker stands in front of everything the cell says. */
837
+ const prefixesCell = (paragraphs, { marker, paragraphIndex }) => paragraphIndex === firstContentParagraph(paragraphs) && (paragraphs[paragraphIndex] ?? "").slice(0, marker.start).trim() === "";
838
+ /** The marker stands behind everything the cell says. */
839
+ const suffixesCell = (paragraphs, { marker, paragraphIndex }) => paragraphIndex === lastContentParagraph(paragraphs) && (paragraphs[paragraphIndex] ?? "").slice(marker.end).trim() === "";
840
+ /**
841
+ * The one row block a table row declares in the lenient form, or `null`.
842
+ *
843
+ * `cells` is the row's cells in document order, each as its paragraph texts in
844
+ * document order.
845
+ *
846
+ * A row qualifies when exactly two block markers reach outside their own
847
+ * paragraph, they are a matching opener/closer pair, the closer sits in a LATER
848
+ * cell than the opener, the opener prefixes its cell and the closer suffixes
849
+ * its cell. Everything else — one half of a pair, two nested pairs, a stray
850
+ * `{{#else}}`, a pair wrapping the paragraphs of a single cell — is left to the
851
+ * engine's existing structure errors rather than guessed at.
852
+ */
853
+ const detectRowBlockPair = (cells) => {
854
+ const located = [];
855
+ for (const [cellIndex, paragraphs] of cells.entries()) for (const [paragraphIndex, text] of paragraphs.entries()) for (const marker of danglingBlockMarkers(text)) located.push({
856
+ cellIndex,
857
+ paragraphIndex,
858
+ marker
859
+ });
860
+ if (located.length !== 2) return null;
861
+ const [open, close] = located;
862
+ if (!open || !close) return null;
863
+ const openKind = open.marker.meta.kind;
864
+ if (!isOpenKind(openKind) || close.marker.meta.kind !== CLOSER_OF[openKind]) return null;
865
+ if (open.cellIndex >= close.cellIndex) return null;
866
+ if (!prefixesCell(cells[open.cellIndex] ?? [], open) || !suffixesCell(cells[close.cellIndex] ?? [], close)) return null;
867
+ return {
868
+ open,
869
+ close
870
+ };
871
+ };
872
+ //#endregion
736
873
  //#region src/index.ts
737
874
  /**
738
875
  * Shared condition evaluation + marker grammar for DOCX templates. Small,
@@ -806,4 +943,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
806
943
  return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
807
944
  };
808
945
  //#endregion
809
- export { BLOCK_DIRECTIVE_KINDS, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, MAX_CONDITION_DEPTH, NUMERIC_FUNCTION_NAMES, assertNever, blockDirectiveLinePattern, classifyMarker, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, countPattern, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, indexPattern, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, markerPattern, numPattern, parseCondition, placeholderPattern, refPattern, renderComposite, renderDeterministicFieldValue, resolvePath, scanInvalidMarkers, scanMarkers, serializeCondition };
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, countPattern, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, indexPattern, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, markerPattern, numPattern, parseCondition, placeholderPattern, refPattern, renderComposite, renderDeterministicFieldValue, resolvePath, scanInvalidMarkers, scanMarkers, serializeCondition };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/template-conditions",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A document-template marker and condition engine: {{...}} marker grammar, parse, compute, serialize, field values.",
5
5
  "keywords": [
6
6
  "condition",
@@ -45,17 +45,17 @@
45
45
  "typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
46
46
  "lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/template-conditions",
47
47
  "lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/template-conditions",
48
- "format": "oxfmt .",
48
+ "format": "bun ../../scripts/run-oxfmt.ts .",
49
49
  "prepack": "bun run build"
50
50
  },
51
51
  "dependencies": {
52
- "@stll/conditions": "^0.3.0",
53
- "better-result": "3.0.0"
52
+ "@stll/conditions": "^0.3.1",
53
+ "better-result": "3.0.1"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@stll/property-testing": "0.0.0",
57
57
  "@stll/typescript-config": "0.0.0",
58
- "bun-types": "1.4.0",
58
+ "bun-types": "1.4.2",
59
59
  "fast-check": "^4.9.0",
60
60
  "tsdown": "0.22.14"
61
61
  },