From b63206c8d88b24c98affa016b4155a9550f2808f Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:29:08 +0530 Subject: [PATCH 1/8] feat: separate undefined from unsupported values JSON cannot represent undefined, functions or symbols, and JSON.stringify treats all three identically: omitted from an object, null in an array. That conflates a representable absence with an unsupported type, and it drops nested data silently, so embedded page data can be missing fields the author expected. - Top-level failures now name the category rather than sharing one message. A function replacer keeps the generic message, because the original value no longer explains the result. - Add the optional onUnsupported option. 'omit' is the default and preserves current behaviour exactly; 'throw' reports the value and its path (user.email, items[2].name) instead of dropping it. - Values are inspected after toJSON and after a function replacer, so a value that becomes unrepresentable is reported too. - onUnsupported: 'throw' rejects an array replacer, which omits properties by design and would give the combination a confusing meaning. The serializer API and all existing behaviour are unchanged. --- index.d.ts | 12 +++ index.mjs | 105 ++++++++++++++++++++++++- test/unsupported-values.test.mjs | 129 +++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 test/unsupported-values.test.mjs diff --git a/index.d.ts b/index.d.ts index 1421e1e..00bb827 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,9 +2,21 @@ export type InlineJsonReplacer = | ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[] +/** + * How to treat values JSON cannot represent (`undefined`, functions, symbols) + * below the top level. + * + * - `'omit'` (default) keeps native `JSON.stringify` behaviour: the property is + * omitted from an object and becomes `null` in an array. + * - `'throw'` reports the value and its path instead of dropping it. Requires a + * function replacer or no replacer. + */ +export type InlineJsonUnsupportedHandling = 'omit' | 'throw' + export interface SerializeInlineJsonOptions { replacer?: InlineJsonReplacer space?: number + onUnsupported?: InlineJsonUnsupportedHandling } export declare function serializeInlineJson( diff --git a/index.mjs b/index.mjs index 60e1abf..af594a4 100644 --- a/index.mjs +++ b/index.mjs @@ -6,6 +6,8 @@ const JSON_ESCAPES = Object.freeze({ '\u2029': '\\u2029', }) +const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw']) + function assertOptions(options) { if (options === undefined) return {} @@ -25,21 +27,116 @@ function assertSpace(space) { } } +function assertUnsupported(onUnsupported) { + if ( + onUnsupported !== undefined && + !UNSUPPORTED_HANDLING.includes(onUnsupported) + ) { + throw new TypeError("Option onUnsupported must be 'omit' or 'throw' when provided.") + } +} + +/** + * Classify a value that JSON cannot represent. + * + * `undefined` is a representable absence; a function or symbol is an + * unsupported type. JSON.stringify treats both the same way — omitted in an + * object, `null` in an array — so the distinction is made here. + * + * @returns {'undefined' | 'function' | 'symbol' | null} null when representable + */ +function categorize(value) { + if (value === undefined) return 'undefined' + + const type = typeof value + if (type === 'function' || type === 'symbol') return type + + return null +} + +function childPath(holderPath, holder, key) { + if (Array.isArray(holder)) return `${holderPath}[${key}]` + return holderPath === '' ? key : `${holderPath}.${key}` +} + +/** + * Wrap a replacer so that values JSON cannot represent are reported with their + * location instead of being dropped. Only used when `onUnsupported` is + * `'throw'`; otherwise the caller's replacer is passed through untouched so + * native behaviour is preserved exactly. + */ +function strictReplacer(replacer) { + const paths = new WeakMap() + + return function trackingReplacer(key, entry) { + const next = replacer === undefined ? entry : replacer.call(this, key, entry) + + if (key === '') { + if (next !== null && typeof next === 'object') paths.set(next, '') + return next + } + + // Every holder reached here was registered before JSON.stringify recursed + // into it: the root is registered on the key === '' call above, and each + // nested object is registered below before it becomes a holder in turn. + const path = childPath(paths.get(this), this, key) + const category = categorize(next) + + if (category !== null) { + throw new TypeError( + `Cannot represent the ${category} value at ${path} as JSON; ` + + "remove it or serialize with onUnsupported: 'omit'.", + ) + } + + if (next !== null && typeof next === 'object') paths.set(next, path) + + return next + } +} + +function topLevelMessage(value, hasFunctionReplacer) { + if (hasFunctionReplacer) { + return 'The top-level value cannot be represented as JSON.' + } + + const category = value === undefined ? 'undefined' : typeof value + + return category === 'undefined' + ? 'The top-level value is undefined and cannot be represented as JSON.' + : `The top-level value is an unsupported ${category} and cannot be represented as JSON.` +} + /** * Serialize a value as JSON that can be placed in the raw-text content of an * HTML script element. * * @param {unknown} value - * @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number }} [options] + * @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number, onUnsupported?: 'omit' | 'throw' }} [options] * @returns {string} */ export function serializeInlineJson(value, options) { - const { replacer, space } = assertOptions(options) + const { replacer, space, onUnsupported } = assertOptions(options) assertSpace(space) - const serialized = JSON.stringify(value, replacer, space) + assertUnsupported(onUnsupported) + + const strict = onUnsupported === 'throw' + const arrayReplacer = Array.isArray(replacer) + + if (strict && arrayReplacer) { + throw new TypeError( + "Option onUnsupported: 'throw' requires a function replacer or no replacer, " + + 'because a property allowlist omits properties by design.', + ) + } + + const effectiveReplacer = strict ? strictReplacer(replacer) : replacer + const serialized = JSON.stringify(value, effectiveReplacer, space) if (serialized === undefined) { - throw new TypeError('The top-level value cannot be represented as JSON.') + throw new TypeError( + topLevelMessage(value, !arrayReplacer && typeof replacer === 'function'), + ) } return serialized.replace(/[<>&\u2028\u2029]/g, (character) => JSON_ESCAPES[character]) diff --git a/test/unsupported-values.test.mjs b/test/unsupported-values.test.mjs new file mode 100644 index 0000000..8b2abb3 --- /dev/null +++ b/test/unsupported-values.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { serializeInlineJson } from '../index.mjs' + +test('distinguishes an undefined top-level value from an unsupported type', () => { + assert.throws( + () => serializeInlineJson(undefined), + /top-level value is undefined/, + ) + assert.throws( + () => serializeInlineJson(Symbol('value')), + /top-level value is an unsupported symbol/, + ) + assert.throws( + () => serializeInlineJson(() => {}), + /top-level value is an unsupported function/, + ) +}) + +test('keeps the generic top-level message when a function replacer produced the gap', () => { + assert.throws( + () => serializeInlineJson({ a: 1 }, { replacer: () => undefined }), + /^TypeError: The top-level value cannot be represented as JSON\.$/, + ) +}) + +test('omits unrepresentable values by default, preserving native behaviour', () => { + const serialized = serializeInlineJson({ + kept: 1, + dropped: undefined, + fn() {}, + sym: Symbol('s'), + list: [1, undefined, 3], + }) + + assert.deepEqual(JSON.parse(serialized), { kept: 1, list: [1, null, 3] }) +}) + +test('omit is accepted explicitly and behaves like the default', () => { + const value = { kept: 1, dropped: undefined } + + assert.equal( + serializeInlineJson(value, { onUnsupported: 'omit' }), + serializeInlineJson(value), + ) +}) + +test('strict mode reports a dropped object property with its path', () => { + assert.throws( + () => serializeInlineJson({ a: { b: undefined } }, { onUnsupported: 'throw' }), + /Cannot represent the undefined value at a\.b as JSON/, + ) +}) + +test('strict mode reports an unsupported array element with its index', () => { + assert.throws( + () => serializeInlineJson({ list: [1, Symbol('s')] }, { onUnsupported: 'throw' }), + /Cannot represent the symbol value at list\[1\] as JSON/, + ) + assert.throws( + () => serializeInlineJson([() => {}], { onUnsupported: 'throw' }), + /Cannot represent the function value at \[0\] as JSON/, + ) +}) + +test('strict mode reports a top-level property without a leading separator', () => { + assert.throws( + () => serializeInlineJson({ missing: undefined }, { onUnsupported: 'throw' }), + /value at missing as JSON/, + ) +}) + +test('strict mode inspects values after toJSON and after a function replacer', () => { + assert.throws( + () => + serializeInlineJson( + { wrapped: { toJSON: () => undefined } }, + { onUnsupported: 'throw' }, + ), + /undefined value at wrapped as JSON/, + ) + + assert.throws( + () => + serializeInlineJson( + { a: 1 }, + { + onUnsupported: 'throw', + replacer(key, entry) { + return key === 'a' ? undefined : entry + }, + }, + ), + /undefined value at a as JSON/, + ) +}) + +test('strict mode passes representable data through unchanged and still escapes', () => { + const value = { nested: { list: [1, ''], ok: null } } + const strict = serializeInlineJson(value, { onUnsupported: 'throw' }) + + assert.equal(strict, serializeInlineJson(value)) + assert.doesNotMatch(strict, /[<>&\u2028\u2029]/u) + assert.deepEqual(JSON.parse(strict), value) +}) + +test('strict mode accepts a non-object top-level value', () => { + assert.equal(serializeInlineJson('plain', { onUnsupported: 'throw' }), '"plain"') + assert.equal(serializeInlineJson(7, { onUnsupported: 'throw' }), '7') +}) + +test('strict mode rejects an array replacer, which omits properties by design', () => { + assert.throws( + () => serializeInlineJson({ a: 1 }, { replacer: ['a'], onUnsupported: 'throw' }), + /requires a function replacer or no replacer/, + ) +}) + +test('rejects an unknown onUnsupported value', () => { + assert.throws( + () => serializeInlineJson({}, { onUnsupported: 'ignore' }), + /must be 'omit' or 'throw'/, + ) + assert.throws( + () => serializeInlineJson({}, { onUnsupported: null }), + /must be 'omit' or 'throw'/, + ) +}) From b5bc332b82a50b40fd020d0c5072f3fc1bdf1e12 Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:29:08 +0530 Subject: [PATCH 2/8] test: round-trip embedded values through an HTML parse Existing tests round-trip through JSON.parse, which never exercises the reason this package escapes < in the first place. Add a fixture that implements the HTML raw-text end-tag rule -- -- and reads the value back out of a rendered page. Covers nested Unicode (combining marks, astral planes, RTL scripts, U+2028 and U+2029) and confirms a hostile payload cannot terminate its containing script element or disturb the markup after it. The fixture is a test helper implementing one spec rule, not a general HTML parser, and is not shipped in the package. --- test/html-embedding.test.mjs | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 test/html-embedding.test.mjs diff --git a/test/html-embedding.test.mjs b/test/html-embedding.test.mjs new file mode 100644 index 0000000..eaa7a71 --- /dev/null +++ b/test/html-embedding.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { serializeInlineJson } from '../index.mjs' + +/** + * Minimal raw-text extractor used as a local parser fixture. + * + * A script element's content is raw text: it ends at the first ``, case-insensitively. That rule is the + * whole reason this package escapes `<`, so the fixture implements exactly it + * rather than depending on a full HTML parser. It is a test fixture, not a + * general-purpose HTML parser. + */ +function readScriptData(html, id) { + const opening = new RegExp(` +
after
+