@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.
- package/package.json +38 -37
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +541 -5
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +601 -9
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +131 -1
- package/src/io/docx-session.ts +672 -2
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +367 -0
- package/src/io/ooxml/workflow-payload.ts +317 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +639 -124
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +139 -14
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +441 -48
- package/src/runtime/layout/public-facet.ts +585 -14
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +80 -16
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +654 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +111 -11
- package/src/ui/editor-shell-view.tsx +21 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +37 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- 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, "&")
|
|
231
|
+
.replace(/</g, "<")
|
|
232
|
+
.replace(/>/g, ">")
|
|
233
|
+
.replace(/"/g, """)
|
|
234
|
+
.replace(/'/g, "'");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function xmlDecode(text: string): string {
|
|
238
|
+
return text
|
|
239
|
+
.replace(/</g, "<")
|
|
240
|
+
.replace(/>/g, ">")
|
|
241
|
+
.replace(/"/g, '"')
|
|
242
|
+
.replace(/'/g, "'")
|
|
243
|
+
.replace(/&/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, "&")
|
|
288
|
+
.replace(/</g, "<")
|
|
289
|
+
.replace(/>/g, ">")
|
|
290
|
+
.replace(/"/g, """)
|
|
291
|
+
.replace(/'/g, "'");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function xmlDecode(text: string): string {
|
|
295
|
+
return text
|
|
296
|
+
.replace(/</g, "<")
|
|
297
|
+
.replace(/>/g, ">")
|
|
298
|
+
.replace(/"/g, '"')
|
|
299
|
+
.replace(/'/g, "'")
|
|
300
|
+
.replace(/&/g, "&");
|
|
301
|
+
}
|