@beyondwork/docx-react-component 1.0.41 → 1.0.43

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.
Files changed (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Minimal XML helpers shared by the `bw:` round-trip builders +
3
+ * parsers. Intentionally not a general-purpose parser — only covers
4
+ * the shapes the bw schema uses: elements with attributes, text +
5
+ * CDATA content, no processing instructions, no mixed namespaces.
6
+ *
7
+ * The canonicalizer in `canonicalize-payload.ts` has its own inline
8
+ * parser because its contract is stricter (attribute sort, sort-key
9
+ * tables). These helpers are for conventional build/parse work where
10
+ * we just need the tree.
11
+ */
12
+
13
+ export interface BwElement {
14
+ kind: "element";
15
+ name: string;
16
+ attributes: Record<string, string>;
17
+ children: BwNode[];
18
+ }
19
+
20
+ export interface BwText {
21
+ kind: "text";
22
+ text: string;
23
+ }
24
+
25
+ export type BwNode = BwElement | BwText;
26
+
27
+ export function parseBwXml(src: string): BwElement {
28
+ let i = 0;
29
+ skipPrologAndWs();
30
+ const root = readElement();
31
+ if (!root) throw new Error("bw-xml: no root element");
32
+ return root;
33
+
34
+ function skipPrologAndWs(): void {
35
+ while (i < src.length) {
36
+ if (src.startsWith("<?", i)) {
37
+ const end = src.indexOf("?>", i);
38
+ if (end < 0) throw new Error("bw-xml: unterminated prolog");
39
+ i = end + 2;
40
+ continue;
41
+ }
42
+ if (src.startsWith("<!--", i)) {
43
+ const end = src.indexOf("-->", i);
44
+ if (end < 0) throw new Error("bw-xml: unterminated comment");
45
+ i = end + 3;
46
+ continue;
47
+ }
48
+ if (/\s/.test(src[i]!)) {
49
+ i += 1;
50
+ continue;
51
+ }
52
+ break;
53
+ }
54
+ }
55
+
56
+ function readElement(): BwElement | null {
57
+ while (i < src.length) {
58
+ if (src.startsWith("<!--", i)) {
59
+ const end = src.indexOf("-->", i);
60
+ if (end < 0) throw new Error("bw-xml: unterminated comment");
61
+ i = end + 3;
62
+ continue;
63
+ }
64
+ if (/\s/.test(src[i]!)) {
65
+ i += 1;
66
+ continue;
67
+ }
68
+ break;
69
+ }
70
+ if (src[i] !== "<") return null;
71
+ if (src.startsWith("</", i)) return null;
72
+ i += 1;
73
+
74
+ const nameStart = i;
75
+ while (i < src.length && !/[\s/>]/.test(src[i]!)) i += 1;
76
+ const name = src.slice(nameStart, i);
77
+
78
+ const attrs: Record<string, string> = {};
79
+ while (i < src.length) {
80
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
81
+ if (src[i] === "/" || src[i] === ">") break;
82
+ const aStart = i;
83
+ while (i < src.length && src[i] !== "=" && !/\s/.test(src[i]!)) i += 1;
84
+ const aName = src.slice(aStart, i);
85
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
86
+ if (src[i] !== "=") {
87
+ attrs[aName] = "";
88
+ continue;
89
+ }
90
+ i += 1;
91
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
92
+ const quote = src[i];
93
+ if (quote !== '"' && quote !== "'") {
94
+ throw new Error(`bw-xml: unquoted attr at ${i}`);
95
+ }
96
+ i += 1;
97
+ const vStart = i;
98
+ while (i < src.length && src[i] !== quote) i += 1;
99
+ attrs[aName] = xmlDecode(src.slice(vStart, i));
100
+ i += 1;
101
+ }
102
+
103
+ if (src[i] === "/") {
104
+ i += 1;
105
+ if (src[i] !== ">") throw new Error("bw-xml: bad self-close");
106
+ i += 1;
107
+ return { kind: "element", name, attributes: attrs, children: [] };
108
+ }
109
+ if (src[i] !== ">") throw new Error("bw-xml: expected >");
110
+ i += 1;
111
+
112
+ const children: BwNode[] = [];
113
+ while (i < src.length) {
114
+ if (src.startsWith("</", i)) {
115
+ i += 2;
116
+ const endStart = i;
117
+ while (i < src.length && src[i] !== ">") i += 1;
118
+ const endName = src.slice(endStart, i).trim();
119
+ if (endName !== name) {
120
+ throw new Error(
121
+ `bw-xml: mismatched close: opened <${name}> got </${endName}>`,
122
+ );
123
+ }
124
+ i += 1;
125
+ return { kind: "element", name, attributes: attrs, children };
126
+ }
127
+ if (src.startsWith("<!--", i)) {
128
+ const end = src.indexOf("-->", i);
129
+ if (end < 0) throw new Error("bw-xml: unterminated comment");
130
+ i = end + 3;
131
+ continue;
132
+ }
133
+ if (src.startsWith("<![CDATA[", i)) {
134
+ const end = src.indexOf("]]>", i);
135
+ if (end < 0) throw new Error("bw-xml: unterminated CDATA");
136
+ children.push({ kind: "text", text: src.slice(i + 9, end) });
137
+ i = end + 3;
138
+ continue;
139
+ }
140
+ if (src[i] === "<") {
141
+ const child = readElement();
142
+ if (child) children.push(child);
143
+ continue;
144
+ }
145
+ const textStart = i;
146
+ while (i < src.length && src[i] !== "<") i += 1;
147
+ const raw = src.slice(textStart, i);
148
+ if (raw.length > 0) {
149
+ children.push({ kind: "text", text: xmlDecode(raw) });
150
+ }
151
+ }
152
+ throw new Error(`bw-xml: unterminated <${name}>`);
153
+ }
154
+ }
155
+
156
+ // ----- build helpers --------------------------------------------------------
157
+
158
+ export function renderElement(
159
+ name: string,
160
+ attrs: Record<string, string | undefined>,
161
+ children: readonly string[] = [],
162
+ ): string {
163
+ const pairs: string[] = [];
164
+ for (const [k, v] of Object.entries(attrs)) {
165
+ if (v === undefined || v === "") continue;
166
+ pairs.push(`${k}="${xmlEncode(v)}"`);
167
+ }
168
+ const head = pairs.length ? `${name} ${pairs.join(" ")}` : name;
169
+ const body = children.filter((c) => c.length > 0).join("");
170
+ if (body.length === 0) return `<${head}/>`;
171
+ return `<${head}>${body}</${name}>`;
172
+ }
173
+
174
+ export function renderText(text: string): string {
175
+ return xmlEncode(text);
176
+ }
177
+
178
+ export function renderCdata(text: string): string {
179
+ const safe = text.replace(/\]\]>/g, "]]]]><![CDATA[>");
180
+ return `<![CDATA[${safe}]]>`;
181
+ }
182
+
183
+ // ----- element traversal helpers -------------------------------------------
184
+
185
+ export function childrenOf(el: BwElement, localName: string): BwElement[] {
186
+ const out: BwElement[] = [];
187
+ for (const child of el.children) {
188
+ if (child.kind === "element" && stripNs(child.name) === localName) {
189
+ out.push(child);
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+
195
+ export function firstChild(
196
+ el: BwElement,
197
+ localName: string,
198
+ ): BwElement | undefined {
199
+ for (const child of el.children) {
200
+ if (child.kind === "element" && stripNs(child.name) === localName) {
201
+ return child;
202
+ }
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ export function textOf(el: BwElement): string {
208
+ return el.children
209
+ .map((c) => (c.kind === "text" ? c.text : ""))
210
+ .join("");
211
+ }
212
+
213
+ export function stripNs(qname: string): string {
214
+ const colon = qname.indexOf(":");
215
+ return colon < 0 ? qname : qname.slice(colon + 1);
216
+ }
217
+
218
+ export function attrNumber(
219
+ value: string | undefined,
220
+ ): number | undefined {
221
+ if (value === undefined || value === "") return undefined;
222
+ const n = Number(value);
223
+ return Number.isFinite(n) ? n : undefined;
224
+ }
225
+
226
+ // ----- encoding -------------------------------------------------------------
227
+
228
+ export function xmlEncode(text: string): string {
229
+ return text
230
+ .replace(/&/g, "&amp;")
231
+ .replace(/</g, "&lt;")
232
+ .replace(/>/g, "&gt;")
233
+ .replace(/"/g, "&quot;")
234
+ .replace(/'/g, "&apos;");
235
+ }
236
+
237
+ export function xmlDecode(text: string): string {
238
+ return text
239
+ .replace(/&lt;/g, "<")
240
+ .replace(/&gt;/g, ">")
241
+ .replace(/&quot;/g, '"')
242
+ .replace(/&apos;/g, "'")
243
+ .replace(/&amp;/g, "&");
244
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Canonical serialization of the `bw:workflowPayload` used as the signing
3
+ * surface for `bw:signature`. Called `bw-canon/1`.
4
+ *
5
+ * Rules (see bw-collab-schema-additions.md §bw:signature):
6
+ * 1. Remove any top-level `bw:signature` element from the tree before
7
+ * serializing.
8
+ * 2. Sort attributes lexicographically by qualified name.
9
+ * 3. Normalize attribute-value whitespace: collapse runs of `\s` to a
10
+ * single space, then trim.
11
+ * 4. Sort children of order-insensitive parents by a keyed tuple
12
+ * (see `SORT_KEYED_CHILDREN`). Children of any parent not listed
13
+ * retain document order.
14
+ * 5. Collapse empty elements to self-closing form.
15
+ * 6. Emit UTF-8 bytes, LF line endings (none in practice — output is
16
+ * single-line), no XML declaration, no BOM.
17
+ *
18
+ * This canonicalizer is intentionally minimal — it parses only the
19
+ * element / attribute / text / CDATA subset the bw schema uses. It does
20
+ * NOT implement C14N; the contract is self-consistent (writer and
21
+ * reader use this same routine) and self-referential (the signature
22
+ * element is excluded from its own input).
23
+ */
24
+ export function canonicalizePayload(xml: string): Uint8Array {
25
+ const tree = parseXml(xml);
26
+ const filtered = stripSignature(tree);
27
+ const out = serialize(filtered);
28
+ return new TextEncoder().encode(out);
29
+ }
30
+
31
+ // ----- AST ------------------------------------------------------------------
32
+
33
+ export interface XmlElement {
34
+ kind: "element";
35
+ name: string;
36
+ attributes: Record<string, string>;
37
+ children: XmlNode[];
38
+ }
39
+
40
+ interface XmlText {
41
+ kind: "text";
42
+ text: string;
43
+ }
44
+
45
+ type XmlNode = XmlElement | XmlText;
46
+
47
+ // ----- Parse ----------------------------------------------------------------
48
+
49
+ function parseXml(src: string): XmlElement {
50
+ let i = 0;
51
+ skipProlog();
52
+
53
+ const root = readElement();
54
+ if (!root) throw new Error("canonicalize: no root element");
55
+ return root;
56
+
57
+ function skipProlog(): void {
58
+ while (i < src.length) {
59
+ if (src.startsWith("<?", i)) {
60
+ const end = src.indexOf("?>", i);
61
+ if (end < 0) throw new Error("canonicalize: unterminated prolog");
62
+ i = end + 2;
63
+ continue;
64
+ }
65
+ if (src.startsWith("<!--", i)) {
66
+ const end = src.indexOf("-->", i);
67
+ if (end < 0) throw new Error("canonicalize: unterminated comment");
68
+ i = end + 3;
69
+ continue;
70
+ }
71
+ if (/\s/.test(src[i]!)) {
72
+ i += 1;
73
+ continue;
74
+ }
75
+ break;
76
+ }
77
+ }
78
+
79
+ function readElement(): XmlElement | null {
80
+ // Skip whitespace + comments between siblings.
81
+ while (i < src.length) {
82
+ if (src.startsWith("<!--", i)) {
83
+ const end = src.indexOf("-->", i);
84
+ if (end < 0) throw new Error("canonicalize: unterminated comment");
85
+ i = end + 3;
86
+ continue;
87
+ }
88
+ if (/\s/.test(src[i]!)) {
89
+ i += 1;
90
+ continue;
91
+ }
92
+ break;
93
+ }
94
+ if (src[i] !== "<") return null;
95
+ if (src.startsWith("</", i)) return null;
96
+ i += 1;
97
+
98
+ const nameStart = i;
99
+ while (i < src.length && !/[\s/>]/.test(src[i]!)) i += 1;
100
+ const name = src.slice(nameStart, i);
101
+
102
+ const attrs: Record<string, string> = {};
103
+ while (i < src.length) {
104
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
105
+ if (src[i] === "/" || src[i] === ">") break;
106
+ const aNameStart = i;
107
+ while (i < src.length && src[i] !== "=" && !/\s/.test(src[i]!)) i += 1;
108
+ const attrName = src.slice(aNameStart, i);
109
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
110
+ if (src[i] !== "=") {
111
+ attrs[attrName] = "";
112
+ continue;
113
+ }
114
+ i += 1;
115
+ while (i < src.length && /\s/.test(src[i]!)) i += 1;
116
+ const quote = src[i];
117
+ if (quote !== '"' && quote !== "'") {
118
+ throw new Error(`canonicalize: unquoted attr at ${i}`);
119
+ }
120
+ i += 1;
121
+ const vStart = i;
122
+ while (i < src.length && src[i] !== quote) i += 1;
123
+ attrs[attrName] = xmlDecode(src.slice(vStart, i));
124
+ i += 1;
125
+ }
126
+
127
+ if (src[i] === "/") {
128
+ i += 1;
129
+ if (src[i] !== ">") throw new Error("canonicalize: bad self-close");
130
+ i += 1;
131
+ return { kind: "element", name, attributes: attrs, children: [] };
132
+ }
133
+ if (src[i] !== ">") throw new Error("canonicalize: expected >");
134
+ i += 1;
135
+
136
+ const children: XmlNode[] = [];
137
+ while (i < src.length) {
138
+ if (src.startsWith("</", i)) {
139
+ i += 2;
140
+ const endNameStart = i;
141
+ while (i < src.length && src[i] !== ">") i += 1;
142
+ const endName = src.slice(endNameStart, i).trim();
143
+ if (endName !== name) {
144
+ throw new Error(
145
+ `canonicalize: mismatched close: opened <${name}> got </${endName}>`,
146
+ );
147
+ }
148
+ i += 1;
149
+ return { kind: "element", name, attributes: attrs, children };
150
+ }
151
+ if (src.startsWith("<!--", i)) {
152
+ const end = src.indexOf("-->", i);
153
+ if (end < 0) throw new Error("canonicalize: unterminated comment");
154
+ i = end + 3;
155
+ continue;
156
+ }
157
+ if (src.startsWith("<![CDATA[", i)) {
158
+ const end = src.indexOf("]]>", i);
159
+ if (end < 0) throw new Error("canonicalize: unterminated CDATA");
160
+ children.push({ kind: "text", text: src.slice(i + 9, end) });
161
+ i = end + 3;
162
+ continue;
163
+ }
164
+ if (src[i] === "<") {
165
+ const child = readElement();
166
+ if (child) children.push(child);
167
+ continue;
168
+ }
169
+ const textStart = i;
170
+ while (i < src.length && src[i] !== "<") i += 1;
171
+ const raw = src.slice(textStart, i);
172
+ if (raw.length > 0) {
173
+ children.push({ kind: "text", text: xmlDecode(raw) });
174
+ }
175
+ }
176
+ throw new Error(`canonicalize: unterminated <${name}>`);
177
+ }
178
+ }
179
+
180
+ // ----- Transform ------------------------------------------------------------
181
+
182
+ function stripSignature(el: XmlElement): XmlElement {
183
+ return {
184
+ ...el,
185
+ children: el.children.filter(
186
+ (c) => !(c.kind === "element" && localName(c.name) === "signature"),
187
+ ),
188
+ };
189
+ }
190
+
191
+ const SORT_KEYED_CHILDREN: Record<string, (el: XmlElement) => string> = {
192
+ participants: (el) => el.attributes["userId"] ?? "",
193
+ votes: (el) => el.attributes["authorId"] ?? "",
194
+ requiredApprovers: (el) => el.attributes["id"] ?? "",
195
+ reactions: (el) =>
196
+ `${el.attributes["emoji"] ?? ""}\u0000${el.attributes["authorId"] ?? ""}`,
197
+ mentions: (el) =>
198
+ `${el.attributes["entryId"] ?? ""}\u0000${el.attributes["offsetInBody"] ?? ""}`,
199
+ counterProposals: (el) => el.attributes["id"] ?? "",
200
+ strippedComments: (el) => el.attributes["commentId"] ?? "",
201
+ strippedParticipants: (el) => el.attributes["id"] ?? "",
202
+ };
203
+
204
+ const CONTAINER_WITH_SORTED_ELEMENTS: Record<string, true> = {
205
+ commentNegotiation: true, // children keyed by commentId
206
+ commentPresentation: true, // children keyed by commentId
207
+ attachments: true, // children keyed by id
208
+ };
209
+
210
+ const CONTAINER_CHILD_KEY: Record<string, (el: XmlElement) => string> = {
211
+ commentNegotiation: (el) => el.attributes["commentId"] ?? "",
212
+ commentPresentation: (el) => el.attributes["commentId"] ?? "",
213
+ attachments: (el) => el.attributes["id"] ?? "",
214
+ };
215
+
216
+ function sortChildren(el: XmlElement): XmlElement {
217
+ const parentLocal = localName(el.name);
218
+ const keyFn =
219
+ SORT_KEYED_CHILDREN[parentLocal] ??
220
+ (CONTAINER_WITH_SORTED_ELEMENTS[parentLocal]
221
+ ? CONTAINER_CHILD_KEY[parentLocal]
222
+ : undefined);
223
+
224
+ const recursed = el.children.map((child) =>
225
+ child.kind === "element" ? sortChildren(child) : child,
226
+ );
227
+
228
+ if (!keyFn) {
229
+ return { ...el, children: recursed };
230
+ }
231
+
232
+ const elements = recursed.filter(
233
+ (c): c is XmlElement => c.kind === "element",
234
+ );
235
+ const others = recursed.filter((c) => c.kind !== "element");
236
+ const sorted = [...elements].sort((a, b) => {
237
+ const ka = keyFn(a);
238
+ const kb = keyFn(b);
239
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
240
+ });
241
+ return { ...el, children: [...others, ...sorted] };
242
+ }
243
+
244
+ // ----- Serialize ------------------------------------------------------------
245
+
246
+ function serialize(el: XmlElement): string {
247
+ const tree = sortChildren(el);
248
+ return emit(tree);
249
+ }
250
+
251
+ function emit(node: XmlNode): string {
252
+ if (node.kind === "text") return xmlEncode(node.text);
253
+ const attrPairs = Object.entries(node.attributes)
254
+ .map(([k, v]) => [k, normalizeWhitespace(v)] as const)
255
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
256
+ const attrStr = attrPairs
257
+ .map(([k, v]) => ` ${k}="${xmlEncode(v)}"`)
258
+ .join("");
259
+
260
+ const content = node.children
261
+ .map(emit)
262
+ .filter((s) => s.length > 0 || preservesSpace())
263
+ .join("");
264
+ if (content.length === 0) {
265
+ return `<${node.name}${attrStr}/>`;
266
+ }
267
+ return `<${node.name}${attrStr}>${content}</${node.name}>`;
268
+ }
269
+
270
+ function preservesSpace(): boolean {
271
+ return false;
272
+ }
273
+
274
+ // ----- Helpers --------------------------------------------------------------
275
+
276
+ function localName(qname: string): string {
277
+ const colon = qname.indexOf(":");
278
+ return colon < 0 ? qname : qname.slice(colon + 1);
279
+ }
280
+
281
+ function normalizeWhitespace(value: string): string {
282
+ return value.replace(/\s+/g, " ").trim();
283
+ }
284
+
285
+ function xmlEncode(text: string): string {
286
+ return text
287
+ .replace(/&/g, "&amp;")
288
+ .replace(/</g, "&lt;")
289
+ .replace(/>/g, "&gt;")
290
+ .replace(/"/g, "&quot;")
291
+ .replace(/'/g, "&apos;");
292
+ }
293
+
294
+ function xmlDecode(text: string): string {
295
+ return text
296
+ .replace(/&lt;/g, "<")
297
+ .replace(/&gt;/g, ">")
298
+ .replace(/&quot;/g, '"')
299
+ .replace(/&apos;/g, "'")
300
+ .replace(/&amp;/g, "&");
301
+ }