@stll/folio-core 0.34.0 → 0.35.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.
@@ -1,6 +1,8 @@
1
1
  import { FolioAIBlock, FolioAIEditSnapshot, FolioAITextRangeHandle } from "./types.js";
2
2
  import { Node } from "prosemirror-model";
3
3
  //#region src/ai-edits/snapshot.d.ts
4
+ /** @internal Numbering references collected during the snapshot's document walk. */
5
+ declare const numberingReferenceKeysOf: (snapshot: FolioAIEditSnapshot) => readonly string[];
4
6
  declare const normalizeFolioAIBlockText: (text: string) => string;
5
7
  /**
6
8
  * Whether a block carries text a reader would see.
@@ -70,4 +72,4 @@ type FolioStoryTable = {
70
72
  declare const folioStoryTables: (doc: Node) => FolioStoryTable[];
71
73
  declare const createFolioAIEditSnapshot: (doc: Node) => FolioAIEditSnapshot;
72
74
  //#endregion
73
- export { FolioStoryTable, createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, trailingBodyBlockId };
75
+ export { FolioStoryTable, createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, trailingBodyBlockId };
@@ -1,8 +1,12 @@
1
1
  import { expectRunFormattingOverrideMarkAttrs } from "../prosemirror/attrs/index.js";
2
2
  import { deriveBlankBlockId, deriveBlockId } from "../types/block-id.js";
3
3
  import { buildCleanBlockText } from "./clean-text.js";
4
+ import { panic } from "better-result";
4
5
  import { TableMap } from "prosemirror-tables";
5
6
  //#region src/ai-edits/snapshot.ts
7
+ const numberingReferenceKeysBySnapshot = /* @__PURE__ */ new WeakMap();
8
+ /** @internal Numbering references collected during the snapshot's document walk. */
9
+ const numberingReferenceKeysOf = (snapshot) => numberingReferenceKeysBySnapshot.get(snapshot) ?? panic("A numbering census was requested for a snapshot that did not record one");
6
10
  const normalizeFolioAIBlockText = (text) => text.replace(/\s+/gu, " ").trim();
7
11
  /**
8
12
  * Whether a block carries text a reader would see.
@@ -126,6 +130,7 @@ const createFolioAIEditSnapshot = (doc) => {
126
130
  const draftBlocks = [];
127
131
  const hashCounts = /* @__PURE__ */ new Map();
128
132
  const usedBlockIds = /* @__PURE__ */ new Set();
133
+ const numberingReferenceKeys = /* @__PURE__ */ new Set();
129
134
  const tableIndexByStart = new Map(folioStoryTables(doc).map(({ start, index }) => [start, index]));
130
135
  const path = [];
131
136
  let blockIndex = 0;
@@ -171,6 +176,8 @@ const createFolioAIEditSnapshot = (doc) => {
171
176
  const displayLabel = getDisplayLabel(node);
172
177
  const styleId = getStyleId(node);
173
178
  const listLevel = getListLevel(node);
179
+ const numberingReferenceKey = getNumberingReferenceKey(node);
180
+ if (numberingReferenceKey) numberingReferenceKeys.add(numberingReferenceKey);
174
181
  const previewRuns = getPreviewRuns(node);
175
182
  const table = getTableLocation({
176
183
  path,
@@ -209,10 +216,12 @@ const createFolioAIEditSnapshot = (doc) => {
209
216
  hashOccurrenceCount: hashCounts.get(draft.anchor.textHash) ?? 0
210
217
  };
211
218
  }
212
- return {
219
+ const snapshot = {
213
220
  blocks,
214
221
  anchors
215
222
  };
223
+ numberingReferenceKeysBySnapshot.set(snapshot, [...numberingReferenceKeys]);
224
+ return snapshot;
216
225
  };
217
226
  const getBlockKind = (node, headingLevel) => {
218
227
  const listMarker = node.attrs["listMarker"];
@@ -241,6 +250,15 @@ const getListLevel = (node) => {
241
250
  const { ilvl } = numPr;
242
251
  return typeof ilvl === "number" && Number.isInteger(ilvl) && ilvl >= 0 ? ilvl : void 0;
243
252
  };
253
+ const getNumberingReferenceKey = (node) => {
254
+ const numPr = node.attrs["numPr"];
255
+ if (typeof numPr !== "object" || numPr === null || !("numId" in numPr)) return null;
256
+ const { numId } = numPr;
257
+ if (typeof numId !== "number" || !Number.isInteger(numId) || numId <= 0) return null;
258
+ const level = "ilvl" in numPr ? numPr.ilvl : void 0;
259
+ if (level !== void 0 && (typeof level !== "number" || !Number.isInteger(level) || level < 0)) return null;
260
+ return `${String(numId)}:${String(level ?? 0)}`;
261
+ };
244
262
  const getStyleId = (node) => {
245
263
  const styleId = node.attrs["styleId"];
246
264
  return typeof styleId === "string" && styleId.length > 0 ? styleId : void 0;
@@ -376,4 +394,4 @@ const isEmptyPreviewRunStyle = ({ bold, italic, underline, strike, fontFamily, f
376
394
  const sameDirectFormatting = (left, right) => left === void 0 && isEmptyPreviewRunStyle(right) || left !== void 0 && left.bold === right.bold && left.italic === right.italic && left.underline === right.underline && left.strike === right.strike && left.fontFamily === right.fontFamily && left.fontSizePt === right.fontSizePt && left.color === right.color;
377
395
  const isUnstyledPreviewRun = ({ bold, italic, underline, strike, fontFamily, fontSizePt, color }) => bold === void 0 && italic === void 0 && underline === void 0 && strike === void 0 && fontFamily === void 0 && fontSizePt === void 0 && color === void 0;
378
396
  //#endregion
379
- export { createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, trailingBodyBlockId };
397
+ export { createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, trailingBodyBlockId };
@@ -1,4 +1,5 @@
1
1
  import { FolioDocxReviewer } from "../ai-edits/headless.js";
2
+ import { numberingReferenceKeysOf } from "../ai-edits/snapshot.js";
2
3
  import { projectTableGeometry } from "../ai-edits/table-geometry.js";
3
4
  import "../document-operations.js";
4
5
  import { pairFolioDocumentStories } from "../document-stories.js";
@@ -114,11 +115,12 @@ const formattingRoundTripFailure = ({ invariant, story, changes, actualBlocks, e
114
115
  };
115
116
  const numberingKey = ({ numId, level }) => `${String(numId)}:${String(level)}`;
116
117
  const sameNumbering = (left, right) => left.format === right.format && left.levelText === right.levelText && left.start === right.start;
117
- const compareNumbering = (base, target) => {
118
+ const compareNumbering = (base, target, referenced) => {
118
119
  const baseLevels = new Map(base.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
119
120
  const targetLevels = new Map(target.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
120
121
  const changes = [];
121
122
  for (const [key, before] of baseLevels) {
123
+ if (!referenced.has(key)) continue;
122
124
  const after = targetLevels.get(key) ?? null;
123
125
  if (after === null || !sameNumbering(before, after)) changes.push({
124
126
  kind: "numbering",
@@ -128,7 +130,7 @@ const compareNumbering = (base, target) => {
128
130
  after
129
131
  });
130
132
  }
131
- for (const [key, after] of targetLevels) if (!baseLevels.has(key)) changes.push({
133
+ for (const [key, after] of targetLevels) if (referenced.has(key) && !baseLevels.has(key)) changes.push({
132
134
  kind: "numbering",
133
135
  numId: after.numId,
134
136
  level: after.level,
@@ -162,8 +164,15 @@ const parseComparison = async (base, target, options) => {
162
164
  });
163
165
  const pairs = [];
164
166
  const unsupported = [];
167
+ const referencedNumberingLevels = /* @__PURE__ */ new Set();
168
+ const collectNumberingReferences = (snapshot) => {
169
+ if (!snapshot) return;
170
+ for (const referenceKey of numberingReferenceKeysOf(snapshot)) referencedNumberingLevels.add(referenceKey);
171
+ };
165
172
  for (const { baseStory, revisedStory: targetStory } of pairFolioDocumentStories(reviewer.listStories().map(({ handle }) => handle), targetReviewer.listStories().map(({ handle }) => handle))) {
166
173
  if (!baseStory) {
174
+ if (!targetStory) panic("A story pair contained neither a base nor a target story");
175
+ collectNumberingReferences(targetReviewer.snapshotStory(targetStory));
167
176
  unsupported.push({
168
177
  reason: "story-missing-in-base",
169
178
  baseStory: null,
@@ -172,6 +181,7 @@ const parseComparison = async (base, target, options) => {
172
181
  continue;
173
182
  }
174
183
  if (!targetStory) {
184
+ collectNumberingReferences(reviewer.snapshotStory(baseStory));
175
185
  unsupported.push({
176
186
  reason: "story-missing-in-target",
177
187
  baseStory,
@@ -181,6 +191,8 @@ const parseComparison = async (base, target, options) => {
181
191
  }
182
192
  const baseSnapshot = reviewer.snapshotStory(baseStory);
183
193
  const targetSnapshot = targetReviewer.snapshotStory(targetStory);
194
+ collectNumberingReferences(baseSnapshot);
195
+ collectNumberingReferences(targetSnapshot);
184
196
  if (!baseSnapshot || !targetSnapshot) {
185
197
  unsupported.push({
186
198
  reason: "story-not-editable",
@@ -208,7 +220,7 @@ const parseComparison = async (base, target, options) => {
208
220
  },
209
221
  packageDate,
210
222
  pairs,
211
- numberingChanges: compareNumbering(reviewer, targetReviewer),
223
+ numberingChanges: compareNumbering(reviewer, targetReviewer, referencedNumberingLevels),
212
224
  unsupported
213
225
  });
214
226
  };
@@ -9,10 +9,16 @@ type DirectiveRange = {
9
9
  /** Exclusive PM doc position of the marker end. */
10
10
  to: number;
11
11
  kind: DirectiveKind;
12
- /** Field path, clause name, or condition/loop expression. */
12
+ /**
13
+ * Field path, clause name, key, condition, or — for a `for` marker — the
14
+ * array path the loop iterates (`{% for row in items %}` ⇒ `items`).
15
+ */
13
16
  expr: string;
14
17
  /** Clause-slot version selector, e.g. "v3" or "latest". */
15
18
  clauseVersion?: string;
19
+ /** Loop alias of a `for` marker (`{% for row in items %}` ⇒ `row`); unset
20
+ * for every other kind. */
21
+ alias?: string;
16
22
  /** True for block directives that occupy their own paragraph. */
17
23
  block: boolean;
18
24
  };
@@ -20,15 +26,16 @@ type DirectiveRange = {
20
26
  * Nesting depth (0-based) of every block-directive opener, derived purely from
21
27
  * the scanned ranges by containment: walk the block openers/closers in document
22
28
  * order with a kind-aware stack, and record each opener's depth as the stack size
23
- * before it is pushed. Only `block:true` if/each pairs participate (inline markers
29
+ * before it is pushed. Only `block:true` if/for pairs participate (inline markers
24
30
  * resolve within a paragraph and get no rail).
25
31
  *
26
32
  * Matching is kind-aware so a mid-edit / unbalanced template stays sane: a closer
27
- * pops the nearest opener of the *same family* ({{/if}} ⇒ {{#if}}, {{/each}} ⇒
28
- * {{#each}}), dropping any still-open openers nested above it; a closer with no
29
- * matching opener is ignored (never decrements a foreign block's depth). A blind
30
- * open/close counter would mis-count here: e.g. a stray {{/each}} between {{#if}}
31
- * and a nested {{#each}} would wrongly pull the inner {{#each}} back to depth 0.
33
+ * pops the nearest opener of the *same family* (`{% endif %}``{% if %}`,
34
+ * `{% endfor %}` ⇒ `{% for %}`), dropping any still-open openers nested above it;
35
+ * a closer with no matching opener is ignored (never decrements a foreign block's
36
+ * depth). A blind open/close counter would mis-count here: e.g. a stray
37
+ * `{% endfor %}` between `{% if %}` and a nested `{% for %}` would wrongly pull
38
+ * the inner `{% for %}` back to depth 0.
32
39
  *
33
40
  * Keyed by the opener's `from` PM position, which is unique per marker, so the
34
41
  * overlay can look a band's depth up from its opener range. This is a pure
@@ -3,41 +3,42 @@ import { collectBlockChunks, joinChunks, offsetToDocPos } from "./pmTextScan.js"
3
3
  import { PluginKey } from "prosemirror-state";
4
4
  import { assertNever, isBlockDirectiveKind, scanMarkers } from "@stll/template-conditions";
5
5
  //#region src/prosemirror/plugins/templateDirectives.ts
6
- /** The display expression for a marker (field path, clause name, key, condition). */
6
+ /** The display expression for a marker (field path, clause name, key,
7
+ * condition, loop array path, loop property). */
7
8
  const directiveExpr = (meta) => {
8
9
  switch (meta.kind) {
9
10
  case "placeholder": return meta.expr;
10
11
  case "clause": return meta.name;
11
12
  case "num":
12
13
  case "ref": return meta.key;
14
+ case "loop": return meta.property;
13
15
  case "if":
14
- case "elseif":
15
- case "each": return meta.expr;
16
- case "index":
17
- case "count":
16
+ case "elif": return meta.expr;
17
+ case "for": return meta.path;
18
18
  case "else":
19
19
  case "endif":
20
- case "endeach": return "";
20
+ case "endfor": return "";
21
21
  default: return assertNever(meta);
22
22
  }
23
23
  };
24
- /** Block-directive openers ({{#if}}, {{#each}}) that start a gutter-rail band. */
25
- const BLOCK_OPENER_KINDS = /* @__PURE__ */ new Set(["if", "each"]);
26
- /** Block-directive closers ({{/if}}, {{/each}}) that end a gutter-rail band. */
27
- const BLOCK_CLOSER_KINDS = /* @__PURE__ */ new Set(["endif", "endeach"]);
24
+ /** Block-directive openers (`{% if %}`, `{% for %}`) that start a gutter-rail band. */
25
+ const BLOCK_OPENER_KINDS = /* @__PURE__ */ new Set(["if", "for"]);
26
+ /** Block-directive closers (`{% endif %}`, `{% endfor %}`) that end a gutter-rail band. */
27
+ const BLOCK_CLOSER_KINDS = /* @__PURE__ */ new Set(["endif", "endfor"]);
28
28
  /**
29
29
  * Nesting depth (0-based) of every block-directive opener, derived purely from
30
30
  * the scanned ranges by containment: walk the block openers/closers in document
31
31
  * order with a kind-aware stack, and record each opener's depth as the stack size
32
- * before it is pushed. Only `block:true` if/each pairs participate (inline markers
32
+ * before it is pushed. Only `block:true` if/for pairs participate (inline markers
33
33
  * resolve within a paragraph and get no rail).
34
34
  *
35
35
  * Matching is kind-aware so a mid-edit / unbalanced template stays sane: a closer
36
- * pops the nearest opener of the *same family* ({{/if}} ⇒ {{#if}}, {{/each}} ⇒
37
- * {{#each}}), dropping any still-open openers nested above it; a closer with no
38
- * matching opener is ignored (never decrements a foreign block's depth). A blind
39
- * open/close counter would mis-count here: e.g. a stray {{/each}} between {{#if}}
40
- * and a nested {{#each}} would wrongly pull the inner {{#each}} back to depth 0.
36
+ * pops the nearest opener of the *same family* (`{% endif %}``{% if %}`,
37
+ * `{% endfor %}` ⇒ `{% for %}`), dropping any still-open openers nested above it;
38
+ * a closer with no matching opener is ignored (never decrements a foreign block's
39
+ * depth). A blind open/close counter would mis-count here: e.g. a stray
40
+ * `{% endfor %}` between `{% if %}` and a nested `{% for %}` would wrongly pull
41
+ * the inner `{% for %}` back to depth 0.
41
42
  *
42
43
  * Keyed by the opener's `from` PM position, which is unique per marker, so the
43
44
  * overlay can look a band's depth up from its opener range. This is a pure
@@ -54,12 +55,14 @@ const computeBlockDepths = (ranges) => {
54
55
  stack.push(range.kind);
55
56
  continue;
56
57
  }
57
- const wantOpener = range.kind === "endif" ? "if" : "each";
58
+ const wantOpener = range.kind === "endif" ? "if" : "for";
58
59
  const matchIdx = stack.lastIndexOf(wantOpener);
59
60
  if (matchIdx !== -1) stack.length = matchIdx;
60
61
  }
61
62
  return depths;
62
63
  };
64
+ /** The loop alias a `for` marker binds, or undefined for every other kind. */
65
+ const directiveAlias = (meta) => meta.kind === "for" ? meta.alias : void 0;
63
66
  const scanDirectives = (doc) => {
64
67
  const ranges = [];
65
68
  for (const chunks of collectBlockChunks(doc)) {
@@ -69,24 +72,28 @@ const scanDirectives = (doc) => {
69
72
  const sole = lineMarkers.length === 1 ? lineMarkers[0] : void 0;
70
73
  if (sole && sole.raw === trimmed && isBlockDirectiveKind(sole.meta.kind)) {
71
74
  const last = chunks.at(-1);
75
+ const alias = directiveAlias(sole.meta);
72
76
  ranges.push({
73
77
  from: chunks[0]?.start ?? 0,
74
78
  to: last ? last.end ?? last.start + last.text.length : 0,
75
79
  kind: sole.meta.kind,
76
80
  expr: directiveExpr(sole.meta),
77
- block: true
81
+ block: true,
82
+ ...alias !== void 0 ? { alias } : {}
78
83
  });
79
84
  continue;
80
85
  }
81
86
  for (const marker of scanMarkers(joined)) {
82
87
  const clauseVersion = marker.meta.kind === "clause" ? marker.meta.version : void 0;
88
+ const alias = directiveAlias(marker.meta);
83
89
  ranges.push({
84
90
  from: offsetToDocPos(chunks, marker.start),
85
91
  to: offsetToDocPos(chunks, marker.end, "end"),
86
92
  kind: marker.meta.kind,
87
93
  expr: directiveExpr(marker.meta),
88
94
  block: false,
89
- ...clauseVersion !== void 0 ? { clauseVersion } : {}
95
+ ...clauseVersion !== void 0 ? { clauseVersion } : {},
96
+ ...alias !== void 0 ? { alias } : {}
90
97
  });
91
98
  }
92
99
  }
@@ -24,8 +24,8 @@ const atTriggerBoundary = (state, pos) => {
24
24
  /** Whether `pos` falls strictly inside an existing template directive. The
25
25
  * slash activations insert markers as raw text rather than going through
26
26
  * `insertInline`'s overlap guard, so opening here would nest markers — e.g. a
27
- * `/` typed after `#if ` inside `{{#if condition}}` could produce
28
- * `{{#if {{field}}}}`, which the scanner/fill grammar cannot interpret.
27
+ * `/` typed after `if ` inside `{% if condition %}` could produce
28
+ * `{% if {{ field }} %}`, which the scanner/fill grammar cannot interpret.
29
29
  * Boundaries are exclusive: a caret right before `{{` or after `}}` is fine. */
30
30
  const insideDirective = (state, pos) => getTemplateDirectives(state).some((range) => pos > range.from && pos < range.to);
31
31
  /** Whether the `/` that opened the trigger is still present at `from`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -117,7 +117,7 @@
117
117
  "dependencies": {
118
118
  "@stll/docx-core": "^0.19.2",
119
119
  "@stll/docx-utils": "^0.1.0",
120
- "@stll/template-conditions": "^0.1.0",
120
+ "@stll/template-conditions": "^0.4.0",
121
121
  "better-result": "3.0.1",
122
122
  "csstype": "^3.1.3",
123
123
  "dompurify": "^3.4.13",