diff --git a/src/expressions/function-call-expression.ts b/src/expressions/function-call-expression.ts index 9de0772..05bf870 100644 --- a/src/expressions/function-call-expression.ts +++ b/src/expressions/function-call-expression.ts @@ -69,7 +69,10 @@ const BUILT_IN_FUNCTIONS: Record String(arg).length, - 'normalize-space': (_ctx, arg) => String(arg).trim().replace(/\s+/g, ' '), + 'normalize-space': (_ctx, arg) => + String(arg) + .replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, '') + .replace(/[\t\n\r ]+/g, ' '), contains: (_ctx, str, sub) => String(str).includes(String(sub)), 'starts-with': (_ctx, str, sub) => String(str).startsWith(String(sub)), 'ends-with': (_ctx, str, sub) => String(str).endsWith(String(sub)), @@ -935,7 +938,11 @@ export class XPathFunctionCall extends XPathExpression { private normalizeSpace(args: XPathResult[], context: XPathContext): string { const str = args.length === 0 ? this.stringValue([], context) : this.convertToString(args[0]); - return str.trim().replace(/\s+/g, ' '); + // Per the XPath/XQuery F&O spec, normalize-space() only collapses XML whitespace + // (#x9, #xA, #xD, #x20). Using JS's .trim()/\s here is wrong because ECMA-262 + // treats U+00A0 (NBSP) and other Unicode spaces as whitespace too, which silently + // corrupts real content (e.g. NBSP in text) into a plain space. + return str.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, '').replace(/[\t\n\r ]+/g, ' '); } private substringBefore(args: XPathResult[]): string { diff --git a/tests/expressions/expressions.test.ts b/tests/expressions/expressions.test.ts index f157767..4214662 100644 --- a/tests/expressions/expressions.test.ts +++ b/tests/expressions/expressions.test.ts @@ -549,6 +549,26 @@ describe('Expression Evaluation', () => { expect(result).toBe('text'); }); + it('should preserve non-breaking spaces (U+00A0) instead of collapsing them to a regular space', () => { + // Per the XPath/XQuery F&O spec, normalize-space() only treats #x9/#xA/#xD/#x20 as + // whitespace. U+00A0 (NBSP) is a distinct character and must be left untouched - + // it must not be collapsed or trimmed away like ordinary XML whitespace. + const result = evaluate("normalize-space('Foo Bar')"); + expect(result).toBe('Foo Bar'); + }); + + it('should trim surrounding XML whitespace while preserving an internal NBSP run', () => { + const result = evaluate("normalize-space(' Foo  Bar ')"); + expect(result).toBe('Foo  Bar'); + }); + + it('should evaluate normalize-space without argument preserving NBSP in node text', () => { + const result = evaluate('normalize-space()', { + node: { textContent: ' Foo Bar ', nodeType: 1, nodeName: 'test' } as any, + }); + expect(result).toBe('Foo Bar'); + }); + it('should evaluate translate', () => { const result = evaluate("translate('hello', 'el', 'ip')"); expect(result).toBe('hippo');