Skip to content

Commit 920179a

Browse files
committed
feat: adding xml validator with unittests: numbering
1 parent 251b102 commit 920179a

8 files changed

Lines changed: 353 additions & 15 deletions

File tree

packages/super-editor/src/core/Editor.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,8 @@ export class Editor extends EventEmitter {
15411541
fieldsHighlightColor,
15421542
);
15431543

1544+
this.#validateDocumentExport();
1545+
15441546
if (exportXmlOnly || exportJsonOnly) return documentXml;
15451547

15461548
const customXml = this.converter.schemaToXml(this.converter.convertedXml['docProps/custom.xml'].elements[0]);
@@ -1888,4 +1890,16 @@ export class Editor extends EventEmitter {
18881890
const validator = new SuperValidator({ editor: this, dryRun: false, debug: false });
18891891
validator.validateActiveDocument();
18901892
}
1893+
1894+
/**
1895+
* Run the SuperValidator's on document upon export to check and fix potential known issues.
1896+
* @returns {void}
1897+
*/
1898+
#validateDocumentExport() {
1899+
if (this.options.isHeaderOrFooter || this.options.isChildEditor) return;
1900+
1901+
/** @type {import('./super-validator/index.js').SuperValidator} */
1902+
const validator = new SuperValidator({ editor: this, dryRun: false, debug: false });
1903+
validator.validateDocumentExport();
1904+
}
18911905
}

packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,9 @@ function getNumberingDefinitions(docx) {
547547
let importListDefs = {};
548548
definitions.forEach((el) => {
549549
const numId = Number(el.attributes['w:numId']);
550-
importListDefs[numId] = el;
550+
if (Number.isInteger(numId)) {
551+
importListDefs[numId] = el;
552+
}
551553
});
552554

553555
const listDefsEntries = Object.entries(importListDefs);

packages/super-editor/src/core/super-validator/super-validator.js

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @ts-check
22
import { createLogger } from './logger/logger.js';
33
import { StateValidators } from './validators/state/index.js';
4+
import { XmlValidators } from './validators/xml/index.js';
45

56
/**
67
* @typedef {import('./types.js').ElementInfo} ElementInfo
@@ -25,6 +26,9 @@ export class SuperValidator {
2526
/** @type {any} */
2627
#stateValidators;
2728

29+
/** @type {any} */
30+
#xmlValidators;
31+
2832
/** @type {Set<string>} */
2933
#requiredNodeTypes;
3034

@@ -42,35 +46,42 @@ export class SuperValidator {
4246
this.logger = createLogger(this.debug);
4347

4448
// Initialize validators and collect their requirements
45-
const { validators, nodeTypes, markTypes } = this.#initializeValidators();
46-
this.#stateValidators = validators;
49+
const { stateValidators, xmlValidators, nodeTypes, markTypes } = this.#initializeValidators();
50+
this.#stateValidators = stateValidators;
51+
this.#xmlValidators = xmlValidators;
4752
this.#requiredNodeTypes = nodeTypes;
4853
this.#requiredMarkTypes = markTypes;
4954
}
5055

5156
/**
5257
* Initialize all validators and collect their element requirements
53-
* @returns {{ validators: Record<string, ValidatorFunction>, nodeTypes: Set<string>, markTypes: Set<string> }}
58+
* @returns {{ stateValidators: Record<string, ValidatorFunction>, xmlValidators: Record<string, ValidatorFunction>, nodeTypes: Set<string>, markTypes: Set<string> }}
5459
*/
5560
#initializeValidators() {
5661
const requiredNodes = new Set();
5762
const requiredMarks = new Set();
5863

59-
const validators = Object.fromEntries(
60-
Object.entries(StateValidators).map(([key, factory]) => {
61-
const validatorLogger = this.logger.withPrefix(key);
62-
/** @type {ValidatorFunction} */
63-
const validator = factory({ editor: this.#editor, logger: validatorLogger });
64+
const initializeValidatorSet = (validatorFactories) => {
65+
return Object.fromEntries(
66+
Object.entries(validatorFactories).map(([key, factory]) => {
67+
const validatorLogger = this.logger.withPrefix(key);
68+
/** @type {ValidatorFunction} */
69+
const validator = factory({ editor: this.#editor, logger: validatorLogger });
70+
71+
// Collect requirements from this validator
72+
this.#collectValidatorRequirements(validator, requiredNodes, requiredMarks);
6473

65-
// Collect requirements from this validator
66-
this.#collectValidatorRequirements(validator, requiredNodes, requiredMarks);
74+
return [key, validator];
75+
}),
76+
);
77+
};
6778

68-
return [key, validator];
69-
}),
70-
);
79+
const stateValidators = initializeValidatorSet(StateValidators);
80+
const xmlValidators = initializeValidatorSet(XmlValidators);
7181

7282
return {
73-
validators,
83+
stateValidators: stateValidators,
84+
xmlValidators: xmlValidators,
7485
nodeTypes: requiredNodes,
7586
markTypes: requiredMarks,
7687
};
@@ -174,4 +185,32 @@ export class SuperValidator {
174185
this.logger.debug('Results:', validationResults);
175186
return { modified: hasModifiedDocument, results: validationResults };
176187
}
188+
189+
/**
190+
* Validate the exported document in the editor. Triggered automatically on editor export.
191+
* @returns {{ modified: boolean, results: Array<{ key: string, results: string[] }> }}
192+
*/
193+
validateDocumentExport() {
194+
const { tr } = this.#editor.state;
195+
const { dispatch } = this.#editor.view;
196+
197+
let hasModifiedDocument = false;
198+
const validationResults = [];
199+
200+
// Run XML validators
201+
Object.entries(this.#xmlValidators).forEach(([key, validator]) => {
202+
this.logger.debug(`🕵 Validating export with ${key}...`);
203+
204+
const { results, modified } = validator();
205+
validationResults.push({ key, results });
206+
207+
hasModifiedDocument = hasModifiedDocument || modified;
208+
});
209+
210+
if (!this.dryRun && hasModifiedDocument) dispatch(tr);
211+
else this.logger.debug('DRY RUN: No export changes applied to the document.');
212+
213+
this.logger.debug('Export validation results:', validationResults);
214+
return { modified: hasModifiedDocument, results: validationResults };
215+
}
177216
}

packages/super-editor/src/core/super-validator/super-validator.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { SuperValidator } from './super-validator.js';
33
import { StateValidators } from './validators/state/index.js';
4+
import { XmlValidators } from './validators/xml/index.js';
45

56
vi.mock('./logger/logger.js', () => ({
67
createLogger: vi.fn(() => ({
@@ -18,6 +19,13 @@ vi.mock('./validators/state/index.js', () => ({
1819
},
1920
}));
2021

22+
vi.mock('./validators/xml/index.js', () => ({
23+
XmlValidators: {
24+
xmlA: vi.fn(),
25+
xmlB: vi.fn(),
26+
},
27+
}));
28+
2129
describe('SuperValidator', () => {
2230
let mockEditor, mockDoc, mockView, mockTr;
2331

@@ -114,4 +122,54 @@ describe('SuperValidator', () => {
114122

115123
expect(result.results).toHaveLength(2);
116124
});
125+
126+
describe('validateDocumentExport', () => {
127+
it('calls all XML validators and aggregates results; dispatches when modified', () => {
128+
const xmlValidatorA = vi.fn(() => ({ modified: true, results: ['fixed numbering'] }));
129+
const xmlValidatorB = vi.fn(() => ({ modified: false, results: [] }));
130+
131+
XmlValidators.xmlA.mockReturnValue(xmlValidatorA);
132+
XmlValidators.xmlB.mockReturnValue(xmlValidatorB);
133+
134+
const instance = new SuperValidator({ editor: mockEditor });
135+
136+
const result = instance.validateDocumentExport();
137+
138+
expect(result.modified).toBe(true);
139+
expect(result.results).toEqual([
140+
{ key: 'xmlA', results: ['fixed numbering'] },
141+
{ key: 'xmlB', results: [] },
142+
]);
143+
144+
expect(mockView.dispatch).toHaveBeenCalled();
145+
});
146+
147+
it('does not dispatch if no XML validator modified the document', () => {
148+
const xmlValidatorA = vi.fn(() => ({ modified: false, results: [] }));
149+
const xmlValidatorB = vi.fn(() => ({ modified: false, results: [] }));
150+
151+
XmlValidators.xmlA.mockReturnValue(xmlValidatorA);
152+
XmlValidators.xmlB.mockReturnValue(xmlValidatorB);
153+
154+
const instance = new SuperValidator({ editor: mockEditor });
155+
const result = instance.validateDocumentExport();
156+
157+
expect(result.modified).toBe(false);
158+
expect(mockView.dispatch).not.toHaveBeenCalled();
159+
});
160+
161+
it('does not dispatch if dryRun is true even when modified', () => {
162+
const xmlValidatorA = vi.fn(() => ({ modified: true, results: ['something'] }));
163+
const xmlValidatorB = vi.fn(() => ({ modified: false, results: [] }));
164+
165+
XmlValidators.xmlA.mockReturnValue(xmlValidatorA);
166+
XmlValidators.xmlB.mockReturnValue(xmlValidatorB);
167+
168+
const instance = new SuperValidator({ editor: mockEditor, dryRun: true });
169+
const result = instance.validateDocumentExport();
170+
171+
expect(result.modified).toBe(true);
172+
expect(mockView.dispatch).not.toHaveBeenCalled();
173+
});
174+
});
117175
});

packages/super-editor/src/core/super-validator/types.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
* @returns {{ modified: boolean, results: string[] }} - Validation results and whether the document was modified.
2121
*/
2222

23+
/**
24+
* @typedef {function} XmlValidator
25+
* @param {Editor} editor - The editor instance to validate.
26+
* @param {ValidatorLogger} logger - Logger for validation messages.
27+
* @returns {{ modified: boolean, results: string[] }} - Validation results and whether the document was modified.
28+
*/
29+
2330
/**
2431
* @typedef {Object} ElementInfo
2532
* @property {import('prosemirror-model').Node} [node]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// @ts-check
2+
import { createNumberingValidator } from './numbering/numbering-validator.js';
3+
4+
/**
5+
* @typedef {Object} XmlValidator
6+
* @property {import('../../types.js').XmlValidator} numberingValidator - Validator for numbering.xml file.
7+
*/
8+
export const XmlValidators = {
9+
numberingValidator: createNumberingValidator,
10+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// @ts-check
2+
/**
3+
* @typedef {import('../../../types.js').Editor} Editor
4+
* @typedef {import('../../../types.js').ValidatorLogger} ValidatorLogger
5+
*/
6+
export function createNumberingValidator({ editor, logger }) {
7+
return () => {
8+
const results = [];
9+
let modified = false;
10+
11+
const convertedXml = editor?.converter?.convertedXml;
12+
const path = 'word/numbering.xml';
13+
const numbering = convertedXml?.[path];
14+
15+
if (!numbering || !numbering.elements?.length || !numbering.elements[0].elements?.length) {
16+
results.push(`${path} is not a valid xml`);
17+
return { results, modified };
18+
}
19+
20+
const removed = [];
21+
let elements = numbering.elements[0];
22+
pruneInvalidNumNodes(elements, removed);
23+
24+
if (removed.length) {
25+
modified = true;
26+
results.push(`Removed invalid <w:num> by numId:` + removed.join(', '));
27+
logger?.debug?.(`Removed invalid <w:num> by numId: ${removed.join(', ')}`);
28+
} else {
29+
results.push('No <w:num> entries with null/invalid numId found.');
30+
}
31+
return { results, modified };
32+
};
33+
}
34+
/**
35+
* Recursively walks an xml tree and removes <w:num> with bad numId.
36+
* Mutates the tree in place.
37+
* @param {any} node - xml node (expects .elements array for element nodes)
38+
* @param {string[]} removed - collects removed numId values
39+
*/
40+
function pruneInvalidNumNodes(node, removed) {
41+
if (!node || !Array.isArray(node.elements)) return;
42+
43+
const next = [];
44+
for (const el of node.elements) {
45+
if (el?.type === 'element') {
46+
if (el.name === 'w:num') {
47+
const attrs = el.attributes || {};
48+
const raw = attrs['w:numId'];
49+
const v = raw == null ? null : String(raw).trim();
50+
const isInvalid = v == null || v === '' || /^null$/i.test(v) || !/^\d+$/.test(v);
51+
52+
if (isInvalid) {
53+
removed.push(v ?? 'missing node');
54+
continue;
55+
}
56+
}
57+
if (Array.isArray(el.elements) && el.elements.length) {
58+
pruneInvalidNumNodes(el, removed);
59+
}
60+
}
61+
next.push(el);
62+
}
63+
64+
node.elements = next;
65+
}

0 commit comments

Comments
 (0)