@endevops/effect-codec-xml 0.0.1 → 0.1.0-beta.2
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/README.md +81 -62
- package/dist/codec.d.ts +17 -9
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +29 -18
- package/dist/codec.js.map +1 -1
- package/dist/conventions.d.ts +4 -4
- package/dist/conventions.js +7 -7
- package/dist/conventions.js.map +1 -1
- package/dist/entities/entity-decoder.d.ts +33 -33
- package/dist/entities/entity-decoder.d.ts.map +1 -1
- package/dist/entities/entity-decoder.js +63 -64
- package/dist/entities/entity-decoder.js.map +1 -1
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/namespaces.js +40 -13
- package/dist/namespaces.js.map +1 -1
- package/dist/naming.d.ts +6 -6
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +3 -3
- package/dist/naming.js.map +1 -1
- package/dist/parse.d.ts +5 -5
- package/dist/parse.js +14 -14
- package/dist/parse.js.map +1 -1
- package/dist/plain-value.js +242 -0
- package/dist/plain-value.js.map +1 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +28 -28
- package/dist/render.js.map +1 -1
- package/dist/xml-error.d.ts +9 -9
- package/dist/xml-error.js +18 -18
- package/dist/xml-error.js.map +1 -1
- package/dist/xml-value.d.ts +7 -7
- package/dist/xml-value.d.ts.map +1 -1
- package/dist/xml-value.js +6 -7
- package/dist/xml-value.js.map +1 -1
- package/package.json +1 -1
- package/src/codec.ts +98 -71
- package/src/conventions.ts +7 -7
- package/src/entities/entity-decoder.ts +98 -99
- package/src/errors.ts +3 -3
- package/src/index.ts +3 -3
- package/src/namespaces.ts +62 -36
- package/src/naming.ts +34 -35
- package/src/parse.ts +26 -26
- package/src/plain-value.ts +312 -0
- package/src/render.ts +44 -44
- package/src/xml-error.ts +18 -18
- package/src/xml-value.ts +10 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-decoder.js","names":["DEFAULT_XML_ENTITIES"],"sources":["../../src/entities/entity-decoder.ts"],"sourcesContent":["// oxlint-disable effecttsgo/effect-succeed-with-void\n// entity-decoder.ts\n//\n// Single-pass, zero-regex decoder. Scan for `&`, read to `;`, resolve, push chunks, join once. Ported from\n// `@nodable/entities@2.2.0` (`src/EntityDecoder.js`) as native TypeScript.\n//\n// Three entity tiers exist and the distinction is the security model, not a performance detail. `input` and\n// `external` entities are injected at runtime — DOCTYPE declarations, and whatever a caller hands the\n// decoder — so they are the untrusted surface and are what the expansion limits count by default. `base` is\n// the five XML predefined entities plus the caller's own `namedEntities`. Numeric references are always\n// `base`: they cannot recurse.\n//\n// Behaviour is transcribed, not corrected. Several upstream quirks are load-bearing for the output a caller\n// already sees — a `&` inside a registered value is not filtered the way the docs claim, `postCheck` is\n// skipped on the two fast paths, C1 codepoints and the FFFE/FFFF noncharacters are not classified at all.\n// Each is called out where it appears, and none of them is repaired, because this class sits in front of XXE\n// and entity-expansion handling where a silent fix is a change to every consumer's output.\n\nimport { Effect, Match, Predicate } from 'effect';\n\nimport { XmlError } from '#/xml-error.ts';\n\nimport { XML as DEFAULT_XML_ENTITIES } from './entity-tables.ts';\n\n// ---------------------------------------------------------------------------\n// Character codes\n//\n// The scan is a hand-rolled `charCodeAt` loop, so the three codes it tests against are named here rather than\n// written as literals. `&` opens a reference, `;` closes one, `#` is what makes a reference numeric.\n// ---------------------------------------------------------------------------\n\nconst CODE_AMPERSAND = 38;\nconst CODE_SEMICOLON = 59;\nconst CODE_HASH = 35;\nconst CODE_LOWER_X = 120;\nconst CODE_UPPER_X = 88;\n\n/**\n * @description The widest entity name {@link EntityDecoder.decode} will look for, in characters. The forward scan for `;` stops once more than this many characters\n * have passed since the `&`, so a longer run is not treated as one entity — it is copied through as literal text. The bound is what keeps a document\n * with a megabyte of non-entity text between two ampersands from being sliced.\n */\nconst MAX_TOKEN_LENGTH = 32;\n\n/**\n * @description The largest codepoint `String.fromCodePoint` accepts, and the bound a numeric reference is checked against before it gets that far. Out of range is\n * `leave`, not `remove`: the reference is preserved verbatim rather than deleted.\n */\nconst MAX_CODE_POINT = 0x10ffff;\n\n/**\n * @description Returned by `#classifyNCR` for a codepoint that carries no minimum action level, which is what distinguishes \"no restriction\" from\n * `NCR_LEVEL.allow` — both end up expanding, but only the first lets `numericAllowed: false` short-circuit the whole pipeline.\n */\nconst NO_MINIMUM_LEVEL = -1;\n\n// ---------------------------------------------------------------------------\n// Entity name validation\n// ---------------------------------------------------------------------------\n\n/**\n * @description Characters that may not appear in an entity name registered through {@link EntityDecoder.setExternalEntities} or\n * {@link EntityDecoder.addExternalEntity}. A name carrying one of these cannot be written as `&name;` at all, so registration refuses it rather than\n * storing a name no document could ever reference. The set is the upstream string verbatim, including its duplicated backslash — a `Set` discards the\n * duplicate, so the effective set is the eighteen characters below.\n */\nconst SPECIAL_CHARS: ReadonlySet<string> = new Set('!?\\\\/[]$%{}^&*()<>|+');\n\n// ---------------------------------------------------------------------------\n// Limit tiers\n// ---------------------------------------------------------------------------\n\n/**\n * @description Injected at runtime: DOCTYPE entities for the current document, and persistent external entities. The untrusted tier, and the only one the\n * expansion limits count by default.\n */\nconst LIMIT_TIER_EXTERNAL = 'external';\n\n/**\n * @description Trusted: the five XML predefined entities, the caller's `namedEntities`, and every numeric reference.\n */\nconst LIMIT_TIER_BASE = 'base';\n\n/**\n * @description Not a tier an entity belongs to but a switch on the tier filter. Selecting it makes every entity count against the limits regardless of where it\n * came from.\n */\nconst LIMIT_TIER_ALL = 'all';\n\n/**\n * @description Which side of the trust boundary an entity came from, as `#tierCounts` and the limit errors name it.\n */\ntype LimitTier = typeof LIMIT_TIER_ALL | typeof LIMIT_TIER_BASE | typeof LIMIT_TIER_EXTERNAL;\n\n/**\n * @description The NCR action levels, in severity order. A higher number is a stricter action, and the resolver takes the maximum of the configured level and the\n * minimum a codepoint range imposes, so a range can only ever make an entity stricter than the caller asked for — never more lenient.\n */\nconst NCR_LEVEL = Object.freeze({ allow: 0, leave: 1, remove: 2, throw: 3 });\n\n/**\n * @description The action names {@link EntityDecoderNCROptions.onNCR} accepts, matching the keys of {@link NCR_LEVEL}.\n */\ntype NcrLevelName = keyof typeof NCR_LEVEL;\n\n/**\n * @description The XML version that governs which codepoint ranges a numeric reference is checked against. Narrowed to the two values the constructor and\n * {@link EntityDecoder.setXmlVersion} can actually store, because `#classifyNCR` compares it with `=== 1.0` and a third value would silently disable\n * the XML 1.0 C0 check.\n */\ntype XmlVersion = 1 | 1.1;\n\n/**\n * @description The C0 control codes XML 1.0 §2.2 permits as literal characters. Every other code in U+0001–U+001F is prohibited.\n */\nconst XML10_ALLOWED_C0: ReadonlySet<number> = new Set([0x09, 0x0a, 0x0d]);\n\n// ---------------------------------------------------------------------------\n// Hook actions\n// ---------------------------------------------------------------------------\n\n/**\n * @description What an {@link EntityRegistrationHook} returns. Use {@link ENTITY_ACTION} rather than the bare strings, so a typo is a type error instead of an\n * entity that is accepted by default.\n */\nexport type EntityHookAction = 'allow' | 'block' | 'throw';\n\n/**\n * @description A function-valued entity replacement: the `val` of the legacy `{ regex, val }` form when it is not a string. This decoder cannot use one — a\n * function has no meaning without the regex it was meant to be matched against — so such an entry is dropped at registration rather than expanded.\n */\nexport type EntityValFn = (match: string, captured: string, ...rest: Array<unknown>) => string;\n\n/**\n * @description Called once per entity _at registration time_, never during {@link EntityDecoder.decode}. Receives the name without `&` and `;` and the resolved\n * string value, after any `{ regex, val }` envelope has been unwrapped.\n *\n * @param name - The entity name, e.g. `brand`.\n * @param value - The string the entity expands to.\n *\n * @returns The action to take. Anything other than `block` and `throw` is treated as `allow`, so an unrecognised return value never rejects an entity\n * by accident.\n */\nexport type EntityRegistrationHook = (name: string, value: string) => EntityHookAction;\n\n/**\n * @description The three actions a registration hook can return, as a frozen object. Prefer it over the bare strings: the literals stay narrow string-literal\n * types, so `ENTITY_ACTION.BLOK` fails to compile instead of silently registering the entity.\n *\n * @example\n * ```typescript\n * const decoder = new EntityDecoder({\n * onInputEntity: () => ENTITY_ACTION.BLOCK,\n * });\n * ```;\n */\nexport const ENTITY_ACTION: Readonly<{ ALLOW: 'allow'; BLOCK: 'block'; THROW: 'throw' }> = Object.freeze({\n ALLOW: 'allow',\n BLOCK: 'block',\n THROW: 'throw',\n} as const);\n\n// ---------------------------------------------------------------------------\n// Option types\n// ---------------------------------------------------------------------------\n\n/**\n * @description Which entity categories count toward the expansion limits.\n *\n * - `'external'` — only input/runtime + persistent external entities. The default, and the only one that ignores the built-in XML entities.\n * - `'base'` — only the built-in XML entities, the caller's `namedEntities`, and numeric references.\n * - `'all'` — every entity regardless of tier.\n * - `Array<'external' | 'base'>` — an explicit combination. An empty array is honoured literally: nothing counts, so the limits can never trip.\n */\nexport type ApplyLimitsTo = 'external' | 'base' | 'all' | Array<'external' | 'base'>;\n\n/**\n * @description Ceilings on what a single document's entity references may cost. Both are cumulative across {@link EntityDecoder.decode} calls until\n * {@link EntityDecoder.reset}, and both default to `0`, meaning unlimited. `0` — and any negative or non-numeric value — is unlimited, because the\n * runtime tests `> 0` rather than truthiness of the configured number.\n */\nexport interface EntityDecoderLimitOptions {\n /**\n * @description Maximum number of tracked entity references expanded per document. The check is `> maxTotalExpansions`, so a limit of `2` allows two expansions\n * and throws on the third.\n *\n * @default 0\n */\n maxTotalExpansions?: number;\n\n /**\n * @description Maximum number of characters _added_ by expansion per document. Only the surplus counts: a reference whose replacement is no longer than the\n * `&token;` it replaces contributes zero, and a shrinking one contributes nothing and cannot trip the limit.\n *\n * @default 0\n */\n maxExpandedLength?: number;\n\n /**\n * @description Which tiers count against both limits. Defaults to `'external'`, which is what keeps the built-in entities — including every numeric reference —\n * from being able to trip a limit on a document the caller already trusts.\n *\n * @default 'external'\n */\n applyLimitsTo?: ApplyLimitsTo;\n}\n\n/**\n * @description Policy for numeric character references. The three fields are flattened into numeric levels at construction so the decode loop never re-reads the\n * object.\n */\nexport interface EntityDecoderNCROptions {\n /**\n * @description The XML version whose codepoint restrictions apply. `1.0` prohibits the C0 controls U+0001–U+001F other than tab, newline and carriage return;\n * `1.1` does not, since it permits them when written as references. Any value other than `1.1` is read as `1.0`.\n *\n * @default 1.0\n */\n xmlVersion?: 1.0 | 1.1;\n\n /**\n * @description The base action for every numeric reference. Codepoint ranges that carry a minimum — surrogates always, the XML 1.0 C0 controls under `1.0`, and\n * null under `nullNCR` — take the stricter of the two, so this is a floor and not an override.\n *\n * @default 'allow'\n */\n onNCR?: 'allow' | 'leave' | 'remove' | 'throw';\n\n /**\n * @description The action for U+0000. `'allow'` and `'leave'` are clamped up to `'remove'`, so a null reference is always at least deleted.\n *\n * @default 'remove'\n */\n nullNCR?: 'remove' | 'throw';\n}\n\n/**\n * @description Construction options for {@link EntityDecoder}. Every field is optional, and the defaults are the permissive ones.\n */\nexport interface EntityDecoderOptions {\n /**\n * @description Extra named entities merged into the `base` map alongside the five XML predefined ones. A string value is used directly; a `{ regex, val }` or `{\n * regx, val }` envelope is unwrapped to its `val`. Anything else — a number, `null`, a function, an envelope whose `val` is a function — is\n * dropped, leaving the name unresolvable rather than failing the construction. Upstream's documentation says a value containing `&` is skipped\n * here, to prevent recursive expansion. It is not: the code stores the value unchanged, and only {@link EntityDecoder.addExternalEntity} checks for\n * `&`. Preserved as-is; see the note on the class.\n *\n * @default null\n */\n namedEntities?: Record<string, string | { regex: RegExp; val: string | EntityValFn }> | null;\n\n /**\n * @description Called once on the finished string. Receives `(resolved, original)` and must return a string; return `original` to reject the expansion outright,\n * or a sanitised form of `resolved` to clean it. It is _not_ called for a string that never reaches the scanning loop — an empty string, a\n * non-string, or any string with no `&` in it. A caller relying on `postCheck` to sanitise therefore has to know that a string with no ampersand is\n * never inspected.\n *\n * @default null\n */\n postCheck?: ((resolved: string, original: string) => string) | null;\n\n /**\n * @description Whether numeric references expand at all. Turning it off leaves every one of them in the output verbatim — _except_ the codepoints that carry a\n * minimum action of `remove` or stricter, which are still handled, because that classification runs first and is what makes the option safe to rely\n * on.\n *\n * @default true\n */\n numericAllowed?: boolean;\n\n /**\n * @description Names to keep as literal `&name;` text, matched against the token with no `&` or `;`. Numeric references are matched as `#38` or `#x26`.\n *\n * @default [ ]\n */\n leave?: Array<string>;\n\n /**\n * @description Names to delete outright, matched the same way as {@link EntityDecoderOptions.leave}. A removed reference is charged to the `external` tier even\n * when the name is a built-in one, so a document full of removed built-ins can trip an `applyLimitsTo: 'external'` limit it would not otherwise be\n * subject to. Preserved as-is; the only in-code comment claims the charge is for unknown references, which is not what distinguishes them.\n *\n * @default [ ]\n */\n remove?: Array<string>;\n\n /**\n * @description Ceilings on expansion count and expanded length. See {@link EntityDecoderLimitOptions}.\n */\n limit?: EntityDecoderLimitOptions;\n\n /**\n * @description Policy for numeric references. See {@link EntityDecoderNCROptions}.\n */\n ncr?: EntityDecoderNCROptions;\n\n /**\n * @description Called once per entity as it is registered through {@link EntityDecoder.setExternalEntities} or {@link EntityDecoder.addExternalEntity}. `block`\n * skips the entity, `throw` aborts the whole registration, anything else registers it. With {@link EntityDecoder.setExternalEntities} a `throw`\n * leaves the previous external map in place, because the replacement is only assigned once every entry has passed.\n *\n * @default null\n */\n onExternalEntity?: EntityRegistrationHook | null;\n\n /**\n * @description Called once per entity as it is registered through {@link EntityDecoder.addInputEntities}. Same contract as\n * {@link EntityDecoderOptions.onExternalEntity}, and unlike it the hook is not the only filter — see the class note on name validation.\n *\n * @default null\n */\n onInputEntity?: EntityRegistrationHook | null;\n}\n\n// ---------------------------------------------------------------------------\n// Internal types\n//\n// Looser than the exported option types on purpose. The runtime inspects whatever it is handed, and an entry it\n// cannot read is dropped rather than rejected, so the helpers have to be able to describe an entry the public\n// types claim cannot exist.\n// ---------------------------------------------------------------------------\n\n/**\n * @description The value side of a registration map as the merge helper reads it: a ready string, or a `{ regex | regx, val }` envelope whose `val` may itself be\n * absent, a string, or a function.\n */\ntype EntityInputValue =\n | string\n | { readonly regex?: RegExp | undefined; readonly regx?: RegExp | undefined; readonly val?: string | EntityValFn | undefined };\n\n/**\n * @description One registration map, or nothing. `null` and `undefined` both mean \"no entities here\", which is how `setExternalEntities(null)` clears the map and\n * how the constructor declines to pass `namedEntities`.\n */\ntype EntityInputMap = Readonly<Record<string, EntityInputValue>> | null | undefined;\n\n/**\n * @description What one reference expanded to, tagged with the tier its limit accounting charges. The field is the replacement text itself for a named entity, the\n * character for a numeric reference, and `''` for a removed one — the three shapes the walk pushes into its output.\n */\ntype ResolvedEntity = { value: string; tier: LimitTier };\n\n/**\n * @description A registration context, as it appears in the error a rejecting hook produces.\n */\ntype HookContext = 'external' | 'input';\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * @description Reject an entity name that could never be written as a reference. `#` is refused positionally rather than by the character sweep, because a name\n * starting with `#` is a numeric reference's token and would collide with `#resolveNCR`. Everything else is refused per character.\n *\n * @param name - The name to check.\n *\n * @returns An effect producing the name, unchanged, so the call can be inlined. Fails with {@link XmlError} and the `InvalidEntityName` reason,\n * carrying the offending character. The `[EntityReplacer]` prefix in the message is preserved verbatim from the original throw, despite naming a\n * class this decoder does not have — it is load-bearing for anything matching on it.\n */\nconst checkEntityName = (name: string): Effect.Effect<string, XmlError> => {\n if (name.charCodeAt(0) === CODE_HASH) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'InvalidEntityName', name, character: '#' },\n message: `[EntityReplacer] Invalid character '#' in entity name: \"${name}\"`,\n })\n );\n }\n for (const ch of name) {\n if (SPECIAL_CHARS.has(ch)) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'InvalidEntityName', name, character: ch },\n message: `[EntityReplacer] Invalid character '${ch}' in entity name: \"${name}\"`,\n })\n );\n }\n }\n return Effect.succeed(name);\n};\n\n/**\n * @description Flatten registration maps into one name to string map, later maps winning over earlier ones for the same name. The result is a null-prototype\n * object, not a `Map`. That is not incidental: a `Map` iterates in pure insertion order, while `Object.keys` lifts array-index-like names to the\n * front in numeric order, and the registration hooks observe that order. A name of `\"2\"` registered after `\"brand\"` reaches the hook first here and\n * second in a `Map`.\n *\n * @param maps - The maps to merge. A falsy entry — `null`, `undefined`, `''`, `0` — contributes nothing rather than throwing.\n *\n * @returns A null-prototype object of own string-valued entries. Nothing from `Object.prototype` can be read out of it, so a document naming\n * `constructor` or `toString` finds nothing. Each entry is read through {@link flattenEntityValue}, so an entry that cannot be reduced to a string\n * is absent rather than present-and-unusable.\n */\nfunction mergeEntityMaps(...maps: ReadonlyArray<EntityInputMap>): Record<string, string> {\n const out: Record<string, string> = Object.create(null);\n for (const map of maps) {\n if (!map) continue;\n for (const key of Object.keys(map)) {\n const value = flattenEntityValue(map[key]);\n if (value !== undefined) out[key] = value;\n }\n }\n return out;\n}\n\n/**\n * @description Reduce one registration entry to the string a reference to it expands to, or to nothing when the entry is a form the scanner has no use for. Three\n * shapes survive: the string itself, and a `{ regex | regx, val }` envelope whose `val` is a string. Everything else — a number, `null`, `undefined`,\n * a bare function, an envelope whose `val` is a function — has no string to substitute, so the name is dropped and a reference to it comes back out\n * as the text it was written as. Dropping is silent on purpose: the runtime inspects whatever it is handed, and failing the construction over one\n * unreadable entry would take every other entity in the table down with it.\n *\n * @param raw - The entry as it arrived, in whatever shape the caller supplied it — including no entry at all, which a table with a hole in it\n * produces.\n *\n * @returns The replacement string, or `undefined` when the entry cannot be read. A name registered to the empty string yields `''`, which is why\n * callers compare against `undefined` rather than testing for emptiness.\n */\nfunction flattenEntityValue(raw: EntityInputValue | undefined): string | undefined {\n if (Predicate.isString(raw)) return raw;\n\n // The `raw &&` in upstream is a null check: every object is truthy, so it only ever rejects\n // `null` and `undefined` here, and the object check then rejects a bare function value.\n if (Predicate.isNullish(raw) || !Predicate.isObject(raw) || raw.val === undefined) return undefined;\n\n const val = raw.val;\n // A function `val` has no scanner equivalent and is dropped, upstream included.\n return Predicate.isString(val) ? val : undefined;\n}\n\n/**\n * @description Read one own entry out of a null-prototype entity map.\n *\n * @param map - The map to read.\n * @param key - The entity name.\n *\n * @returns The registered string, or `undefined` when the name is not an own key. A name registered to the empty string returns `''`, which is why\n * callers must compare against `undefined` rather than test for emptiness.\n */\nfunction ownEntity(map: Readonly<Record<string, string>>, key: string): string | undefined {\n // Upstream tests `name in map`, which reads as \"is this name present at all\". `Object.hasOwn` is\n // the same question asked explicitly, and it is the honest shape for the answer: an absent key\n // yields `undefined` and a present one yields the stored string, so the `string | undefined` this\n // returns is the real type rather than something an assertion has to paper over. The two maps are\n // null-prototype objects, so `in` and `hasOwn` cannot disagree here.\n if (!Object.hasOwn(map, key)) return undefined;\n return map[key];\n}\n\n/**\n * @description Normalise the `applyLimitsTo` option into the set of tiers that count against the limits.\n *\n * @param raw - The configured value.\n *\n * @returns The tier set. An unrecognised string falls back to `external` rather than to no filtering at all, so a typo cannot silently disable the\n * limits. An array is taken as given, which is why an empty array disables limit accounting entirely while an empty string falls back to\n * `external`.\n */\nfunction parseLimitTiers(raw: ApplyLimitsTo | undefined): ReadonlySet<LimitTier> {\n if (!raw || raw === LIMIT_TIER_EXTERNAL) return new Set([LIMIT_TIER_EXTERNAL]);\n if (raw === LIMIT_TIER_ALL) return new Set([LIMIT_TIER_ALL]);\n if (raw === LIMIT_TIER_BASE) return new Set([LIMIT_TIER_BASE]);\n if (Array.isArray(raw)) return new Set(raw);\n return new Set([LIMIT_TIER_EXTERNAL]);\n}\n\n/**\n * @description Read one level out of {@link NCR_LEVEL} by name.\n *\n * @param name - The configured action name, or nothing.\n * @param fallback - The level to use when the name is absent.\n *\n * @returns The level. A name the table does not carry also yields `fallback`, so a value outside the union degrades to the default action rather than\n * to `NaN`.\n */\nfunction ncrLevelOf(name: NcrLevelName | undefined, fallback: number): number {\n if (name === undefined) return fallback;\n return NCR_LEVEL[name] ?? fallback;\n}\n\n/**\n * @description Flatten the `ncr` option into the three numeric fields the decode loop reads, so nothing has to be re-derived per reference.\n *\n * @param ncr - The configured policy, or nothing.\n *\n * @returns The XML version, the base action level, and the null action level already clamped up to `remove`.\n */\nfunction parseNCRConfig(ncr: EntityDecoderNCROptions | undefined): { xmlVersion: XmlVersion; onLevel: number; nullLevel: number } {\n if (!ncr) {\n return { xmlVersion: 1.0, onLevel: NCR_LEVEL.allow, nullLevel: NCR_LEVEL.remove };\n }\n const xmlVersion: XmlVersion = ncr.xmlVersion === 1.1 ? 1.1 : 1.0;\n const onLevel = ncrLevelOf(ncr.onNCR, NCR_LEVEL.allow);\n // Null is never safe to emit, so anything weaker than `remove` is raised to it before it is stored.\n const nullLevel = Math.max(ncrLevelOf(ncr.nullNCR, NCR_LEVEL.remove), NCR_LEVEL.remove);\n return { xmlVersion, onLevel, nullLevel };\n}\n\n/**\n * @description Resolve {@link EntityDecoderOptions.postCheck} to something the decode loop can call unconditionally, or to the identity function. The fallback\n * exists so the path that actually scanned does not have to test for the option, while the two fast paths that return before the scan still skip the\n * call entirely. A non-function value is treated as an absent option rather than rejected, matching the hook rules: a mistyped option disables its\n * feature instead of failing the construction.\n *\n * @param raw - The configured hook, or nothing.\n *\n * @returns The hook itself, or a function returning its first argument.\n */\nfunction readPostCheck(raw: EntityDecoderOptions['postCheck']): (resolved: string, original: string) => string {\n if (Predicate.isFunction(raw)) return raw;\n return r => r;\n}\n\n/**\n * @description Resolve a registration hook option to something safe to call, under the same non-function rule as {@link readPostCheck}.\n *\n * @param raw - The configured hook, or nothing.\n *\n * @returns The hook itself, or `null` for an absent option and for a value that is not a function. `null` is what lets every registration path ask\n * unconditionally: a hook that is not there accepts.\n */\nfunction readHook(raw: EntityRegistrationHook | null | undefined): EntityRegistrationHook | null {\n if (Predicate.isFunction(raw)) return raw;\n return null;\n}\n\n/**\n * @description Read one of the two entity-name lists as a set, under the same missing-value rule as the other options: absent is empty, not an error. The\n * `Array.isArray` test rather than a truthiness one is what keeps a mistyped list from reaching `new Set` and throwing there, so a caller's typo\n * disables the list instead of taking the decoder down. Matching against a set is also why a name in both lists is decided by the order\n * {@link EntityDecoder.decode} consults them in, not by the order the caller wrote them in.\n *\n * @param raw - The configured list, or nothing.\n *\n * @returns The names as a set, empty when the option is absent or is not an array.\n */\nfunction readNameList(raw: Array<string> | undefined): ReadonlySet<string> {\n if (Array.isArray(raw)) return new Set(raw);\n return new Set();\n}\n\n/**\n * @description Scan forward from a `&` for the `;` that would close the reference it opens, giving up once more than {@link MAX_TOKEN_LENGTH} characters have\n * passed since the `&`.\n *\n * @param str - The string being scanned.\n * @param ampersand - The index of the `&`.\n *\n * @returns The index of the closing `;`, or `-1` when the run holds none inside the window. A `;` one character past the `&` is returned rather than\n * refused: that is the empty token `&;`, and the caller decides it is not a reference.\n */\nfunction scanTokenEnd(str: string, ampersand: number): number {\n const len = str.length;\n let j = ampersand + 1;\n while (j < len && str.charCodeAt(j) !== CODE_SEMICOLON && j - ampersand <= MAX_TOKEN_LENGTH) j++;\n if (j >= len || str.charCodeAt(j) !== CODE_SEMICOLON) return -1;\n return j;\n}\n\n/**\n * @description Single-pass, zero-regex entity decoder for XML and HTML content.\n *\n * ### Entity lookup priority\n *\n * 1. **input / runtime** — injected per document through {@link EntityDecoder.addInputEntities}\n * 2. **persistent external** — set through {@link EntityDecoder.setExternalEntities} and {@link EntityDecoder.addExternalEntity}, surviving\n * {@link EntityDecoder.reset}\n * 3. **base** — the five XML predefined entities plus the constructor's `namedEntities` Both input and external resolve as the `external` tier for limit\n * purposes, because both are injected at runtime. Numeric references (`&#NNN;`, `&#xHH;`) resolve directly through `String.fromCodePoint` and are\n * always `base` tier: they cannot recurse, so a limit that counted them would only punish a document that spells its characters out.\n *\n * ### Upstream behaviour preserved\n *\n * Several quirks of the original are kept deliberately, because a consumer's output already depends on them:\n *\n * - A value containing `&` is **not** filtered from `namedEntities` or `setExternalEntities`, contrary to the documentation. Only\n * {@link EntityDecoder.addExternalEntity} checks, and it drops the entry rather than storing it, so the same name registered either way can resolve\n * to nothing.\n * - {@link EntityDecoderOptions.postCheck} is skipped entirely for input that never reaches the scan — an empty string, a non-string, or a string with\n * no `&`.\n * - {@link EntityDecoder.decode} returns a non-string argument unchanged, despite being typed `string`.\n * - The expansion-limit errors are prefixed `EntityReplacer`, not `EntityDecoder`.\n * - Nothing in XML 1.0 §2.2 is enforced for U+007F–U+009F or for the U+FFFE/U+FFFF noncharacters, and the sweep for `&` leaves a name of\n * {@link MAX_TOKEN_LENGTH} + 1 characters unresolvable.\n * - Numeric references are parsed with `parseInt`, so a leading space, sign, or trailing garbage is accepted: `&# 41;`, `&#x+41;` and `)zz;` all\n * decode, and `�x41;` parses as a null reference rather than `A`.\n * - {@link EntityDecoder.addInputEntities} validates no names, so a `#`-prefixed or `&`-bearing name registers without complaint, where the two\n * external setters would throw.\n *\n * @example\n * ```typescript\n * const decoder = new EntityDecoder({ namedEntities: { copy: '©' } });\n * decoder.setExternalEntities({ brand: 'Acme' });\n * decoder.addInputEntities({ version: '1.0' });\n *\n * decoder.decode('&brand; v&version; ©'); // 'Acme v1.0 ©'\n * decoder.decode('&#38;'); // '&&' — one pass, the output is never re-scanned\n *\n * decoder.reset(); // drops the input entities and the counters, keeps the external ones\n * ```;\n */\nexport class EntityDecoder {\n /**\n * @description {@link EntityDecoderLimitOptions.maxTotalExpansions}, or `0` for unlimited. A negative number or `NaN` is also unlimited, since the decode loop\n * tests `> 0`.\n */\n readonly #maxTotalExpansions: number;\n\n /**\n * @description {@link EntityDecoderLimitOptions.maxExpandedLength}, or `0` for unlimited, read the same way as `#maxTotalExpansions`.\n */\n readonly #maxExpandedLength: number;\n\n /**\n * @description {@link EntityDecoderOptions.postCheck}, or the identity function — so the decode loop can call it unconditionally on the path that actually\n * scanned, and never on the two fast paths that return early.\n */\n readonly #postCheck: (resolved: string, original: string) => string;\n\n /**\n * @description The resolved tier filter. See {@link parseLimitTiers}.\n */\n readonly #limitTiers: ReadonlySet<LimitTier>;\n\n /**\n * @description {@link EntityDecoderOptions.numericAllowed}. Only an explicit `false` turns it off, so an absent option cannot disable it.\n */\n readonly #numericAllowed: boolean;\n\n /**\n * @description The five XML predefined entities plus `namedEntities`, merged once at construction and never written again. The built-ins lose to a\n * `namedEntities` entry of the same name, since it is merged second.\n */\n readonly #baseMap: Record<string, string>;\n\n /**\n * @description Persistent external entities, as a null-prototype object. Replaced wholesale by {@link EntityDecoder.setExternalEntities} and added to by\n * {@link EntityDecoder.addExternalEntity}, and never touched by {@link EntityDecoder.reset} — that is the whole distinction from the input map.\n */\n #externalMap: Record<string, string>;\n\n /**\n * @description DOCTYPE entities for the document being processed, as a null-prototype object. Wiped by both {@link EntityDecoder.reset} and\n * {@link EntityDecoder.addInputEntities}.\n */\n #inputMap: Record<string, string>;\n\n /**\n * @description Tracked expansions since the last reset. Cumulative across {@link EntityDecoder.decode} calls, which is what makes a limit a per-document budget\n * rather than a per-call one. Deliberately not reset by a thrown limit error, so the over-limit count is what the error message reports.\n */\n #totalExpansions: number;\n\n /**\n * @description Characters _added_ by expansion since the last reset, accumulated the same way as `#totalExpansions`. Only positive contributions are counted.\n */\n #expandedLength: number;\n\n /**\n * @description {@link EntityDecoderOptions.remove} as a set, or empty. Checked before every other classification, so a name in here is deleted without the name\n * ever being resolved.\n */\n readonly #removeSet: ReadonlySet<string>;\n\n /**\n * @description {@link EntityDecoderOptions.leave} as a set, or empty. Checked after `remove` and before the numeric test, so a name in here is emitted as the\n * original `&name;` text.\n */\n readonly #leaveSet: ReadonlySet<string>;\n\n /**\n * @description The XML version governing numeric classification. Mutable, because a `<?xml version?>` declaration is normally only known after the decoder\n * exists; see {@link EntityDecoder.setXmlVersion}.\n */\n #ncrXmlVersion: XmlVersion;\n\n /**\n * @description {@link EntityDecoderNCROptions.onNCR} as a level from {@link NCR_LEVEL}. A floor, not an override: the resolver takes the maximum of this and\n * whatever minimum a codepoint range imposes.\n */\n readonly #ncrOnLevel: number;\n\n /**\n * @description {@link EntityDecoderNCROptions.nullNCR} as a level from {@link NCR_LEVEL}, already clamped to `remove` or stricter.\n */\n readonly #ncrNullLevel: number;\n\n /**\n * @description {@link EntityDecoderOptions.onExternalEntity}, or `null` when absent or not a function. A non-function is dropped rather than rejected, so a\n * mistyped option disables the hook instead of failing the construction.\n */\n readonly #onExternalEntity: EntityRegistrationHook | null;\n\n /**\n * @description {@link EntityDecoderOptions.onInputEntity}, or `null`, under the same non-function rule as `#onExternalEntity`.\n */\n readonly #onInputEntity: EntityRegistrationHook | null;\n\n /**\n * @description Create a decoder. A factory rather than a constructor, because it refuses a `null` options object. Every field is optional, so `null` is not \"a\n * decoder with the defaults\" — a caller who wrote it meant something the signature does not allow, and a decoder built from it would be\n * indistinguishable from one built from `{}` while hiding the mistake. Saying so is worth a factory; `EntityDecoderOptions` is a plain object and\n * nothing else about construction can fail.\n *\n * @example\n * ```typescript\n * const decoder = yield* EntityDecoder.make({ numericAllowed: false });\n * yield* decoder.decode('café'); // 'café'\n * ```;\n *\n * @param options - Configuration. See {@link EntityDecoderOptions}. Defaults to every field's own default.\n *\n * @returns An effect producing the decoder. Fails with {@link XmlError} and the `MissingOptions` reason for a `null`.\n */\n static make = (options: EntityDecoderOptions = {}): Effect.Effect<EntityDecoder, XmlError> =>\n Predicate.isNullish(options)\n ? Effect.fail(\n new XmlError({\n reason: { _tag: 'MissingOptions', parameter: 'options' },\n message: 'EntityDecoder.make: options is required. Use make({}) for a decoder with every default.',\n })\n )\n : Effect.succeed(new EntityDecoder(options));\n\n /**\n * @description Create a decoder. Every option is resolved here into the flat fields the decode loop reads, so nothing per-reference has to re-derive it. The\n * options whose wrong type disables them rather than failing the construction — the two hooks, the two name lists — are read through\n * {@link readHook} and {@link readNameList}, so that rule is written once instead of four times.\n *\n * @param resolved - Configuration, already checked. See {@link EntityDecoderOptions}.\n */\n private constructor(resolved: EntityDecoderOptions) {\n // `options.limit` is read first, deliberately: it is the first property the original touched, so\n // the property a `null` would have faulted on, and keeping that order means the reason still\n // names it. The option stays a local — every value the decode loop needs is flattened out of it\n // below, so retaining it on the instance would only be a way to observe the option back.\n const limit = resolved.limit ?? {};\n this.#maxTotalExpansions = limit.maxTotalExpansions || 0;\n this.#maxExpandedLength = limit.maxExpandedLength || 0;\n this.#postCheck = readPostCheck(resolved.postCheck);\n this.#limitTiers = parseLimitTiers(limit.applyLimitsTo ?? LIMIT_TIER_EXTERNAL);\n this.#numericAllowed = resolved.numericAllowed ?? true;\n this.#baseMap = mergeEntityMaps(DEFAULT_XML_ENTITIES, resolved.namedEntities || null);\n\n this.#externalMap = Object.create(null);\n this.#inputMap = Object.create(null);\n this.#totalExpansions = 0;\n this.#expandedLength = 0;\n\n this.#removeSet = readNameList(resolved.remove);\n this.#leaveSet = readNameList(resolved.leave);\n\n const ncrConfig = parseNCRConfig(resolved.ncr);\n this.#ncrXmlVersion = ncrConfig.xmlVersion;\n this.#ncrOnLevel = ncrConfig.onLevel;\n this.#ncrNullLevel = ncrConfig.nullLevel;\n\n this.#onExternalEntity = readHook(resolved.onExternalEntity);\n this.#onInputEntity = readHook(resolved.onInputEntity);\n }\n\n /**\n * @description Ask a registration hook about one name and value.\n *\n * @param hook - The hook, or `null`. A `null` hook accepts, which is what lets {@link EntityDecoder.addExternalEntity} call this unconditionally.\n * @param name - The entity name, without `&` or `;`.\n * @param value - The resolved value, after any `{ regex, val }` envelope was unwrapped.\n * @param context - Which registration is in progress, for the error message.\n *\n * @returns An effect producing `true` to register, `false` to skip silently. Fails with {@link XmlError} and the `EntityRejected` reason when the\n * hook returns `throw`. The message quotes the entity, so it is the only record left that a document was rejected.\n */\n #applyRegistrationHook(hook: EntityRegistrationHook | null, name: string, value: string, context: HookContext): Effect.Effect<boolean, XmlError> {\n if (!hook) return Effect.succeed(true); // no hook to ask\n const action = hook(name, value);\n if (action === ENTITY_ACTION.BLOCK) return Effect.succeed(false);\n if (action === ENTITY_ACTION.THROW) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'EntityRejected', context, name },\n message: `[EntityDecoder] Registration of ${context} entity \"&${name};\" was rejected by hook`,\n })\n );\n }\n return Effect.succeed(true); // ALLOW, and anything unrecognised, accepts\n }\n\n /**\n * @description Replace the whole set of persistent external entities. Every key is validated _before_ any value is read, so an invalid name fails even when its\n * value is a form the merge would have dropped. A non-object or `null` map clears the set without validating anything.\n *\n * @param map - The entities to register, or nothing to clear.\n *\n * @returns An effect that registers the map. Fails with {@link XmlError} when a key contains a character from {@link SPECIAL_CHARS} or begins with\n * `#` (`InvalidEntityName`), or when {@link EntityDecoderOptions.onExternalEntity} returns `throw` (`EntityRejected`). A rejection from the hook\n * aborts before the assignment, so the previous map survives.\n */\n setExternalEntities = Effect.fnUntraced(function* (\n this: EntityDecoder,\n map: Record<string, string | { regex: RegExp; val: string | EntityValFn }>\n ): Effect.fn.Return<void, XmlError> {\n if (map) {\n for (const key of Object.keys(map)) {\n yield* checkEntityName(key);\n }\n }\n if (!this.#onExternalEntity) {\n this.#externalMap = mergeEntityMaps(map);\n return;\n }\n // With a hook, values are flattened first and the hook sees what will actually be stored.\n const flat = mergeEntityMaps(map);\n const filtered: Record<string, string> = Object.create(null);\n for (const [name, value] of Object.entries(flat)) {\n if (yield* this.#applyRegistrationHook(this.#onExternalEntity, name, value, 'external')) {\n filtered[name] = value;\n }\n }\n this.#externalMap = filtered;\n });\n\n /**\n * @description Add one persistent external entity, keeping whatever is already registered. This is the only registration path that refuses a value containing\n * `&`; the two map setters store one unchanged. The omission is upstream's, and it is kept: the same name registered through either route can end\n * up resolving, or not resolving at all.\n *\n * @param key - The entity name, without `&` or `;`.\n * @param value - The replacement text.\n *\n * @returns An effect that adds the entity. Fails with {@link XmlError} and the `InvalidEntityName` reason when `key` contains a character from\n * {@link SPECIAL_CHARS} or begins with `#`, or with the `EntityRejected` reason when {@link EntityDecoderOptions.onExternalEntity} returns\n * `throw`.\n */\n addExternalEntity = Effect.fnUntraced(function* (this: EntityDecoder, key: string, value: string): Effect.fn.Return<void, XmlError> {\n yield* checkEntityName(key);\n // The two guards are unreachable from typed code — `value` is a `string` — and are kept for\n // untyped callers, which is the only way to reach them.\n if (Predicate.isString(value) && value.indexOf('&') === -1) {\n if (yield* this.#applyRegistrationHook(this.#onExternalEntity, key, value, 'external')) {\n this.#externalMap[key] = value;\n }\n }\n });\n\n /**\n * @description Register the DOCTYPE entities for the document about to be decoded, replacing any previous set and clearing both counters. Unlike the external\n * setters, no name is validated: a `#`-prefixed name, or one containing `&` or `<`, registers without complaint. A `#`-prefixed name is then\n * unreachable, since `decode` routes `#`-prefixed tokens to the numeric pipeline first.\n *\n * @param map - The entities to register, or nothing to clear.\n *\n * @returns An effect that registers the map. Fails with {@link XmlError} and the `EntityRejected` reason when\n * {@link EntityDecoderOptions.onInputEntity} returns `throw`. The counters have already been cleared by then.\n */\n addInputEntities = Effect.fnUntraced(function* (\n this: EntityDecoder,\n map: Record<string, string | { regx: RegExp; val: string | EntityValFn } | { regex: RegExp; val: string | EntityValFn }>\n ): Effect.fn.Return<void, XmlError> {\n // Cleared first and unconditionally, so registering entities is itself the start of a new\n // document's budget — including when the call goes on to fail.\n this.#totalExpansions = 0;\n this.#expandedLength = 0;\n if (!this.#onInputEntity) {\n this.#inputMap = mergeEntityMaps(map);\n return;\n }\n const flat = mergeEntityMaps(map);\n const filtered: Record<string, string> = Object.create(null);\n for (const [name, value] of Object.entries(flat)) {\n if (yield* this.#applyRegistrationHook(this.#onInputEntity, name, value, 'input')) {\n filtered[name] = value;\n }\n }\n this.#inputMap = filtered;\n });\n\n /**\n * @description Start a new document: drop the input entities and both counters. The persistent external entities, the base map, the limits, the NCR policy and\n * the XML version all survive, which is the difference between this and constructing a fresh decoder.\n *\n * @returns This decoder, so a call can be chained onto the document it ends.\n */\n reset(): this {\n this.#inputMap = Object.create(null);\n this.#totalExpansions = 0;\n this.#expandedLength = 0;\n return this;\n }\n\n /**\n * @description Set the XML version used to classify numeric references, once a `<?xml version=\"…\"?>` declaration has been read. Only the exact number `1.1`\n * selects XML 1.1; `1.0`, `1.15`, `'1.1'` and `NaN` all become `1.0`, so the stricter classification is the default rather than the looser one.\n *\n * @param version - The declared version.\n *\n * @returns Nothing.\n */\n setXmlVersion(version: number): void {\n this.#ncrXmlVersion = version === 1.1 ? 1.1 : 1.0;\n }\n\n /**\n * @description Expand every entity reference in a string, in one pass. The output is never re-scanned, so no expansion can produce a _second_ one: a registered\n * value that itself contains reference text reaches the caller as that literal text, unexpanded. What the limits bound is the growth of this single\n * pass — how much one round of expansion can add. Three inputs return before the scan and therefore never reach\n * {@link EntityDecoderOptions.postCheck}: a non-string, the empty string, and any string with no `&` in it. The scan itself is `#expandAll`; what\n * this method adds is the three inputs that skip it and the single join of what it collected.\n *\n * @example\n * ```typescript\n * import { Effect } from 'effect';\n * import { EntityDecoder } from '@endevops/effect-xml-codec';\n *\n * const decoder = new EntityDecoder({ namedEntities: { copy: '©' } });\n * Effect.runSync(Effect.orElseSucceed(decoder.addExternalEntity('brand', 'Acme'), () => undefined));\n * Effect.runSync(decoder.decode('&brand; ©')); // 'Acme ©'\n * ```;\n *\n * @param str - The string to decode.\n *\n * @returns An effect producing the decoded string. A non-string argument comes back as the same non-string, which the `string` return type does not\n * describe but callers passing untyped values depend on. Fails with {@link XmlError} when a numeric reference is prohibited under the configured\n * policy (`ProhibitedCharacterReference`), or when a tracked tier would exceed {@link EntityDecoderLimitOptions.maxTotalExpansions}\n * (`ExpansionLimitExceeded`) or {@link EntityDecoderLimitOptions.maxExpandedLength} (`ExpandedLengthLimitExceeded`). The two limit messages keep\n * the `EntityReplacer` prefix from the original throw, which named a class this decoder does not have.\n */\n decode = Effect.fnUntraced(function* (this: EntityDecoder, str: string): Effect.fn.Return<string, XmlError> {\n if (!Predicate.isString(str) || str.length === 0) return str;\n if (str.indexOf('&') === -1) return str; // nothing here can be a reference\n\n const chunks = yield* this.#expandAll(str);\n\n // `chunks` is empty exactly when nothing was replaced, in which case the input is its own result.\n const result = chunks.length === 0 ? str : chunks.join('');\n\n return this.#postCheck(result, str);\n });\n\n /**\n * @description Walk the string once and collect the pieces of every reference that resolved. Two advance rules make the walk terminate and keep it correct: an\n * `&` that turns out to open nothing moves the cursor by one character rather than to the end of its run, so a second `&` in the same text is still\n * found; and a reference that did resolve moves it to just past the `;`, so the text that was substituted for it is never looked at again — that is\n * what makes the pass single, and a registered value containing `&` cannot expand a second level. What a reference becomes is `#resolveToken`'s to\n * decide and what it costs is `#chargeExpansion`'s to apply, which leaves the scanning here as the only thing with a rule of its own.\n *\n * @param str - The string to expand. It always holds at least one `&` and is never empty, or the caller would have returned before reaching the\n * walk.\n *\n * @returns An effect producing the pieces in order. The array is empty exactly when nothing was replaced, which the caller reads as \"the input is\n * its own result\". Fails with {@link XmlError} and the reason the offending reference carries — `ProhibitedCharacterReference`,\n * `ExpansionLimitExceeded` or `ExpandedLengthLimitExceeded`.\n */\n #expandAll = Effect.fnUntraced(function* (this: EntityDecoder, str: string): Effect.fn.Return<Array<string>, XmlError> {\n const chunks: Array<string> = [];\n const len = str.length;\n let last = 0; // start of the next unprocessed literal run\n let i = 0;\n\n while (i < len) {\n if (str.charCodeAt(i) !== CODE_AMPERSAND) {\n i++;\n continue;\n }\n\n const end = scanTokenEnd(str, i);\n if (end <= i + 1) {\n // Nothing to resolve: no `;` inside the scan window, or an empty token (`&;`). A bare ampersand\n // rather than a reference, so advance past the `&` only and let the rest of the run be copied.\n i++;\n continue;\n }\n\n const token = str.slice(i + 1, end);\n const resolved = yield* this.#resolveToken(token);\n if (resolved === undefined) {\n // Left, unparseable or unknown: leave the text alone and resume scanning just after the `&`.\n i++;\n continue;\n }\n\n if (i > last) chunks.push(str.slice(last, i));\n chunks.push(resolved.value);\n last = end + 1;\n i = last;\n\n yield* this.#chargeExpansion(token, resolved.value, resolved.tier);\n }\n\n if (last < len) chunks.push(str.slice(last));\n\n return chunks;\n });\n\n /**\n * @description Decide what one reference expands to. The lists and maps are consulted in the one order the runtime uses, and the first that matches wins:\n *\n * 1. `remove` — deleted outright, without the name ever being resolved, so the name need not exist.\n * 2. `leave` — emitted as the original `&token;`, and charged to nothing.\n * 3. A `#`-prefixed token — the numeric pipeline, which is the only one of the four that can fail. Classification runs before any decision about\n * `numericAllowed`, because the ranges that carry a minimum have to be caught whichever way that option is set.\n * 4. Anything else — resolved against the input map, then the external map, then the base map.\n *\n * @param token - The reference's token, e.g. `brand` or `#38`, with the `&` and the `;` already stripped. Never empty: the scanner drops `&;`\n * before calling.\n *\n * @returns An effect producing what the reference expands to and the tier to charge it to, or `undefined` to leave it as written and charge it\n * nothing. `undefined` covers all three ways of leaving a reference alone — a listed `leave` name, a numeric reference that is out of range, and\n * a name registered nowhere — and none of them is distinguishable from outside. Fails with {@link XmlError} and the\n * `ProhibitedCharacterReference` reason when the numeric policy throws on the codepoint.\n */\n #resolveToken = Effect.fnUntraced(function* (this: EntityDecoder, token: string): Effect.fn.Return<ResolvedEntity | undefined, XmlError> {\n if (this.#removeSet.has(token)) {\n // Deleted without being resolved, so the name need not exist. Upstream guards this charge with\n // `if (tier === undefined)`, and its `tier` is declared without an initialiser, so the branch is\n // unconditionally taken and the charge always lands on `external` — whatever tier the name would\n // have resolved in. That is why a document full of removed built-ins can trip an `external` limit\n // nothing it wrote could otherwise reach. Kept as written, since that is a behaviour a caller may\n // already be relying on.\n return { value: '', tier: LIMIT_TIER_EXTERNAL };\n }\n\n // Emitted as the original `&token;`. The walk advances only past the `&` and leaves the `;` to be\n // copied by the next literal run, which is what makes the text come back unchanged.\n if (this.#leaveSet.has(token)) return undefined;\n\n if (token.charCodeAt(0) === CODE_HASH) {\n const character = yield* this.#resolveNCR(token);\n // `''` for a removal and the character for an allow are both real replacements; `undefined` is the\n // numeric pipeline's own way of saying \"leave it as written\".\n if (character === undefined) return undefined;\n return { value: character, tier: LIMIT_TIER_BASE };\n }\n\n return this.#resolveName(token);\n });\n\n /**\n * @description Charge one expansion against the ceilings, or against neither. An expansion counts only when its tier passes `#tierCounts` and at least one\n * ceiling is configured, so a decoder with no limits set does no accounting at all, and an entity in a tier the filter excludes is free. Each\n * ceiling is guarded separately rather than left to its own check, because an unconfigured ceiling is not a ceiling of zero: `maxExpandedLength: 0`\n * means unlimited, so a decoder with only a count limit must not accumulate length it will then be compared against.\n *\n * @param token - The reference's token, with the `&` and `;` stripped. Its width is the baseline the expansion is measured against.\n * @param replacement - What the reference expanded to, including `''` for a removal.\n * @param tier - The tier the expansion is charged to.\n *\n * @returns An effect that fails with {@link XmlError} once a ceiling is exceeded, and succeeds otherwise. The count is checked before the length,\n * so a document that breaches both is reported against the count.\n */\n #chargeExpansion = Effect.fnUntraced(function* (\n this: EntityDecoder,\n token: string,\n replacement: string,\n tier: LimitTier\n ): Effect.fn.Return<void, XmlError> {\n const counts = this.#maxTotalExpansions > 0;\n const grows = this.#maxExpandedLength > 0;\n if (!counts && !grows) return;\n if (!this.#tierCounts(tier)) return;\n\n if (counts) yield* this.#countExpansion();\n if (grows) yield* this.#countExpandedLength(token, replacement);\n });\n\n /**\n * @description Add one expansion to the running total and compare it against {@link EntityDecoderLimitOptions.maxTotalExpansions}. The comparison is `>` rather\n * than `>=`, so a limit of `n` allows exactly `n` expansions and throws on the `n + 1`th. That is a contract — the option's own documentation\n * states it — and the kind of off-by-one a tidy-up changes by accident. The counter is deliberately not reset before failing: the over-limit total\n * is what the error message reports, and {@link EntityDecoder.reset} is the caller's way to start a new document.\n *\n * @returns An effect that fails with {@link XmlError} and the `ExpansionLimitExceeded` reason once the count is past the ceiling, and succeeds\n * otherwise. The `EntityReplacer` prefix in the message is preserved verbatim from the original throw, despite naming a class this decoder does\n * not have.\n */\n #countExpansion(): Effect.Effect<void, XmlError> {\n this.#totalExpansions++;\n if (this.#totalExpansions > this.#maxTotalExpansions) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'ExpansionLimitExceeded', actual: this.#totalExpansions, limit: this.#maxTotalExpansions },\n message: `[EntityReplacer] Entity expansion count limit exceeded: ${this.#totalExpansions} > ${this.#maxTotalExpansions}`,\n })\n );\n }\n return Effect.void;\n }\n\n /**\n * @description Add one expansion's surplus to the running total and compare it against {@link EntityDecoderLimitOptions.maxExpandedLength}. Only the surplus\n * counts, and only upward: a reference whose replacement is no longer than the `&token;` it replaces contributes zero, and a shrinking one\n * contributes nothing and cannot trip the limit at all. That is what makes the ceiling a bound on growth rather than on document size.\n *\n * @param token - The reference's token, with the `&` and `;` stripped. The two delimiters count towards what the expansion displaced.\n * @param replacement - What the reference expanded to, including `''` for a removal.\n *\n * @returns An effect that fails with {@link XmlError} and the `ExpandedLengthLimitExceeded` reason once the total is past the ceiling, and succeeds\n * otherwise. The `EntityReplacer` prefix in the message is preserved verbatim from the original throw, for the same reason as in\n * `#countExpansion`.\n */\n #countExpandedLength(token: string, replacement: string): Effect.Effect<void, XmlError> {\n const delta = replacement.length - (token.length + 2);\n if (delta <= 0) return Effect.void;\n\n this.#expandedLength += delta;\n if (this.#expandedLength > this.#maxExpandedLength) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'ExpandedLengthLimitExceeded', actual: this.#expandedLength, limit: this.#maxExpandedLength },\n message: `[EntityReplacer] Expanded content length limit exceeded: ${this.#expandedLength} > ${this.#maxExpandedLength}`,\n })\n );\n }\n return Effect.void;\n }\n\n /**\n * @description Decide whether an entity of a given tier is charged against the limits.\n *\n * @param tier - The tier the replacement is charged to. Every expansion that reaches here carries one — a name deleted before it was ever resolved\n * still carries the `external` tier — so there is no absent case to answer.\n *\n * @returns `true` when it counts. `'all'` short-circuits, so a filter naming every tier charges everything regardless of which map it came from.\n */\n #tierCounts(tier: LimitTier): boolean {\n if (this.#limitTiers.has(LIMIT_TIER_ALL)) return true;\n return this.#limitTiers.has(tier);\n }\n\n /**\n * @description Resolve a named entity token, with the `&` and `;` already stripped.\n *\n * @param name - The token, e.g. `brand`.\n *\n * @returns The value and the tier to charge it to, or `undefined` when the name is registered nowhere. A name registered to the empty string\n * resolves to `''` rather than to `undefined`, so it deletes the reference instead of leaving it alone.\n */\n #resolveName(name: string): ResolvedEntity | undefined {\n // Input and external share the `external` tier: both are injected at runtime, and that is the\n // surface the limits exist to bound.\n const fromInput = ownEntity(this.#inputMap, name);\n if (fromInput !== undefined) return { value: fromInput, tier: LIMIT_TIER_EXTERNAL };\n\n const fromExternal = ownEntity(this.#externalMap, name);\n if (fromExternal !== undefined) return { value: fromExternal, tier: LIMIT_TIER_EXTERNAL };\n\n const fromBase = ownEntity(this.#baseMap, name);\n if (fromBase !== undefined) return { value: fromBase, tier: LIMIT_TIER_BASE };\n\n return undefined;\n }\n\n /**\n * @description Find the strictest action a codepoint's range requires. Checked in this order:\n *\n * 1. U+0000 — governed by `nullNCR`, already clamped to `remove` or stricter\n * 2. U+D800–U+DFFF — surrogates, always `remove`, under every policy and both XML versions\n * 3. U+0001–U+001F other than tab, newline, carriage return — XML 1.0 only, `remove` Nothing else is classified. U+007F–U+009F (C1) and the\n * U+FFFE/U+FFFF noncharacters are not checked, even though XML 1.0 §2.2 prohibits them and the `xmlVersion` option's own documentation claims C1\n * is only permitted under 1.1. Both gaps are upstream's and are kept.\n *\n * @param cp - The codepoint.\n *\n * @returns The minimum level from {@link NCR_LEVEL}, or {@link NO_MINIMUM_LEVEL} when the codepoint carries none.\n */\n #classifyNCR(cp: number): number {\n if (cp === 0) return this.#ncrNullLevel;\n\n if (cp >= 0xd800 && cp <= 0xdfff) return NCR_LEVEL.remove;\n\n if (this.#ncrXmlVersion === 1.0 && cp >= 0x01 && cp <= 0x1f && !XML10_ALLOWED_C0.has(cp)) {\n return NCR_LEVEL.remove;\n }\n\n return NO_MINIMUM_LEVEL;\n }\n\n /**\n * @description Turn a resolved action level into a replacement.\n *\n * @param action - A level from {@link NCR_LEVEL}. A level outside the four known ones falls through to the allow behaviour, so a bad level cannot\n * produce a wrong string — it can only fail open.\n * @param token - The raw token, e.g. `#38`, for the error message.\n * @param cp - The codepoint, for the error message.\n *\n * @returns An effect producing the character for `allow`, `''` for `remove`, and `undefined` for `leave` — which the caller reads as \"emit the\n * original `&token;`\". Fails with {@link XmlError} and the `ProhibitedCharacterReference` reason for `throw`, naming both the token and the\n * codepoint.\n */\n #applyNCRAction(action: number, token: string, cp: number): Effect.Effect<string | undefined, XmlError> {\n return Match.value(action).pipe(\n Match.when(NCR_LEVEL.allow, () => Effect.succeed(String.fromCodePoint(cp))),\n Match.when(NCR_LEVEL.remove, () => Effect.succeed('')),\n // oxlint-disable-next-line effecttsgo/effect-succeed-with-void\n Match.when(NCR_LEVEL.leave, () => Effect.succeed(undefined)),\n Match.when(NCR_LEVEL.throw, () =>\n Effect.fail(\n new XmlError({\n reason: { _tag: 'ProhibitedCharacterReference', token, codepoint: cp },\n message: `[EntityDecoder] Prohibited numeric character reference &${token}; ` + `(U+${cp.toString(16).toUpperCase().padStart(4, '0')})`,\n })\n )\n ),\n Match.orElse(() => Effect.succeed(String.fromCodePoint(cp)))\n );\n }\n\n /**\n * @description The full numeric-reference pipeline for one `#`-prefixed token.\n *\n * 1. Parse the codepoint, decimal or hex.\n * 2. Reject NaN, negatives, and anything above {@link MAX_CODE_POINT}, leaving the reference as written.\n * 3. Classify the codepoint for a minimum level.\n * 4. If `numericAllowed` is off and no minimum reaches `remove`, leave the reference as written.\n * 5. Take the stricter of the configured level and the minimum.\n * 6. Apply it. Step 4 is why `numericAllowed: false` does not neutralise `onNCR: 'throw'` for surrogates, the XML 1.0 C0 controls or null: their\n * minimum already reaches `remove`, so they are handled no matter what the option says. It does neutralise the throw for every other codepoint.\n * The parse is `parseInt`, which stops at the first character it cannot use. That is upstream's choice and it is permissive: a leading space or\n * `+`, and trailing garbage, are all accepted, and a decimal token beginning `0x` parses as `0` rather than as hex.\n *\n * @param token - The raw token without `&` and `;`, e.g. `#38`, `#x26`, `#X26`.\n *\n * @returns An effect producing the replacement — the empty string meaning \"delete\" — or `undefined` to leave the reference as written. Fails with\n * {@link XmlError} and the `ProhibitedCharacterReference` reason when the effective action is `throw`.\n */\n #resolveNCR(token: string): Effect.Effect<string | undefined, XmlError> {\n const second = token.charCodeAt(1);\n let cp: number;\n if (second === CODE_LOWER_X || second === CODE_UPPER_X) {\n cp = parseInt(token.slice(2), 16);\n } else {\n cp = parseInt(token.slice(1), 10);\n }\n\n // Out of range is `leave` rather than `remove`: an unparseable reference is text, and\n // deleting a document's characters because one of them was malformed is not a safe default.\n if (Number.isNaN(cp) || cp < 0 || cp > MAX_CODE_POINT) return Effect.succeed(undefined);\n\n const minimum = this.#classifyNCR(cp);\n\n if (!this.#numericAllowed && minimum < NCR_LEVEL.remove) return Effect.succeed(undefined);\n\n const effective = minimum === NO_MINIMUM_LEVEL ? this.#ncrOnLevel : Math.max(this.#ncrOnLevel, minimum);\n\n return this.#applyNCRAction(effective, token, cp);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"entity-decoder.js","names":["DEFAULT_XML_ENTITIES"],"sources":["../../src/entities/entity-decoder.ts"],"sourcesContent":["// oxlint-disable effecttsgo/effect-succeed-with-void\n// entity-decoder.ts\n//\n// Single-pass, zero-regex decoder. Scan for `&`, read to `;`, resolve, push chunks, join once. Ported from\n// `@nodable/entities@2.2.0` (`src/EntityDecoder.js`) as native TypeScript.\n//\n// Three entity tiers exist and the distinction is the security model, not a performance detail. `input` and\n// `external` entities are injected at runtime (DOCTYPE declarations, and whatever a caller hands the\n// decoder), so they are the untrusted surface and are what the expansion limits count by default. `base` is\n// the five XML predefined entities plus the caller's own `namedEntities`. Numeric references are always\n// `base`: they cannot recurse.\n//\n// Behaviour is transcribed, not corrected. Several upstream quirks are relied on by the output a caller\n// already sees. A `&` inside a registered value is not filtered the way the docs claim, `postCheck` is\n// skipped on the two fast paths, C1 codepoints and the FFFE/FFFF noncharacters are not classified at all.\n// Each is called out where it appears, and none of them is repaired, because this class sits in front of XXE\n// and entity-expansion handling where a silent fix is a change to every consumer's output.\n\nimport { Effect, Match, Predicate } from 'effect';\n\nimport { XmlError } from '#/xml-error.ts';\n\nimport { XML as DEFAULT_XML_ENTITIES } from './entity-tables.ts';\n\n// ---------------------------------------------------------------------------\n// Character codes\n//\n// The scan is a hand-rolled `charCodeAt` loop, so the three codes it tests against are named here rather than\n// written as literals. `&` opens a reference, `;` closes one, `#` marks a reference as numeric.\n// ---------------------------------------------------------------------------\n\nconst CODE_AMPERSAND = 38;\nconst CODE_SEMICOLON = 59;\nconst CODE_HASH = 35;\nconst CODE_LOWER_X = 120;\nconst CODE_UPPER_X = 88;\n\n/**\n * @description The widest entity name {@link EntityDecoder.decode} will look for, in characters. The forward scan for `;` stops once more than this many characters\n * have passed since the `&`, so a longer run is not treated as one entity. It is copied through as literal text. The bound keeps a document with a\n * megabyte of non-entity text between two ampersands from being sliced.\n */\nconst MAX_TOKEN_LENGTH = 32;\n\n/**\n * @description The largest codepoint `String.fromCodePoint` accepts, and the bound a numeric reference is checked against before it gets that far. Out of range is\n * `leave`, not `remove`: the reference is preserved verbatim rather than deleted.\n */\nconst MAX_CODE_POINT = 0x10ffff;\n\n/**\n * @description Returned by `#classifyNCR` for a codepoint that carries no minimum action level. This value distinguishes \"no restriction\" from `NCR_LEVEL.allow`:\n * both end up expanding, but only the first lets `numericAllowed: false` short-circuit the whole pipeline.\n */\nconst NO_MINIMUM_LEVEL = -1;\n\n// ---------------------------------------------------------------------------\n// Entity name validation\n// ---------------------------------------------------------------------------\n\n/**\n * @description Characters that may not appear in an entity name registered through {@link EntityDecoder.setExternalEntities} or\n * {@link EntityDecoder.addExternalEntity}. A name carrying one of these cannot be written as `&name;` at all, so registration refuses it rather than\n * storing a name no document could ever reference. The set is the upstream string verbatim, including its duplicated backslash. A `Set` discards the\n * duplicate, so the effective set is the eighteen characters below.\n */\nconst SPECIAL_CHARS: ReadonlySet<string> = new Set('!?\\\\/[]$%{}^&*()<>|+');\n\n// ---------------------------------------------------------------------------\n// Limit tiers\n// ---------------------------------------------------------------------------\n\n/**\n * @description Injected at runtime: DOCTYPE entities for the current document, and persistent external entities. The untrusted tier, and the only one the\n * expansion limits count by default.\n */\nconst LIMIT_TIER_EXTERNAL = 'external';\n\n/**\n * @description Trusted: the five XML predefined entities, the caller's `namedEntities`, and every numeric reference.\n */\nconst LIMIT_TIER_BASE = 'base';\n\n/**\n * @description Not a tier an entity belongs to but a switch on the tier filter. Selecting it makes every entity count against the limits regardless of where it\n * came from.\n */\nconst LIMIT_TIER_ALL = 'all';\n\n/**\n * @description Which side of the trust boundary an entity came from, as `#tierCounts` and the limit errors name it.\n */\ntype LimitTier = typeof LIMIT_TIER_ALL | typeof LIMIT_TIER_BASE | typeof LIMIT_TIER_EXTERNAL;\n\n/**\n * @description The NCR action levels, in severity order. A higher number is a stricter action, and the resolver takes the maximum of the configured level and the\n * minimum a codepoint range imposes. A range can therefore only make an entity stricter than the caller asked for, never more lenient.\n */\nconst NCR_LEVEL = Object.freeze({ allow: 0, leave: 1, remove: 2, throw: 3 });\n\n/**\n * @description The action names {@link EntityDecoderNCROptions.onNCR} accepts, matching the keys of {@link NCR_LEVEL}.\n */\ntype NcrLevelName = keyof typeof NCR_LEVEL;\n\n/**\n * @description The XML version that governs which codepoint ranges a numeric reference is checked against. Narrowed to the two values the constructor and\n * {@link EntityDecoder.setXmlVersion} can actually store, because `#classifyNCR` compares it with `=== 1.0` and a third value would silently disable\n * the XML 1.0 C0 check.\n */\ntype XmlVersion = 1 | 1.1;\n\n/**\n * @description The C0 control codes XML 1.0 §2.2 permits as literal characters. Every other code in U+0001 to U+001F is prohibited.\n */\nconst XML10_ALLOWED_C0: ReadonlySet<number> = new Set([0x09, 0x0a, 0x0d]);\n\n// ---------------------------------------------------------------------------\n// Hook actions\n// ---------------------------------------------------------------------------\n\n/**\n * @description What an {@link EntityRegistrationHook} returns. Use {@link ENTITY_ACTION} rather than the bare strings, so a typo is a type error instead of an\n * entity that is accepted by default.\n */\nexport type EntityHookAction = 'allow' | 'block' | 'throw';\n\n/**\n * @description A function-valued entity replacement: the `val` of the legacy `{ regex, val }` form when it is not a string. This decoder cannot use one. A\n * function has no meaning without the regex it was meant to be matched against, so such an entry is dropped at registration rather than expanded.\n */\nexport type EntityValFn = (match: string, captured: string, ...rest: Array<unknown>) => string;\n\n/**\n * @description Called once per entity _at registration time_, never during {@link EntityDecoder.decode}. Receives the name without `&` and `;` and the resolved\n * string value, after any `{ regex, val }` envelope has been unwrapped.\n *\n * @param name - The entity name, e.g. `brand`.\n * @param value - The string the entity expands to.\n *\n * @returns The action to take. Anything other than `block` and `throw` is treated as `allow`, so an unrecognised return value never rejects an entity\n * by accident.\n */\nexport type EntityRegistrationHook = (name: string, value: string) => EntityHookAction;\n\n/**\n * @description The three actions a registration hook can return, as a frozen object. Prefer it over the bare strings: the literals stay narrow string-literal\n * types, so `ENTITY_ACTION.BLOK` fails to compile instead of silently registering the entity.\n *\n * @example\n * ```typescript\n * const decoder = new EntityDecoder({\n * onInputEntity: () => ENTITY_ACTION.BLOCK,\n * });\n * ```;\n */\nexport const ENTITY_ACTION: Readonly<{ ALLOW: 'allow'; BLOCK: 'block'; THROW: 'throw' }> = Object.freeze({\n ALLOW: 'allow',\n BLOCK: 'block',\n THROW: 'throw',\n} as const);\n\n// ---------------------------------------------------------------------------\n// Option types\n// ---------------------------------------------------------------------------\n\n/**\n * @description Which entity categories count toward the expansion limits.\n *\n * - `'external'`: only input/runtime + persistent external entities. The default, and the only one that ignores the built-in XML entities.\n * - `'base'`: only the built-in XML entities, the caller's `namedEntities`, and numeric references.\n * - `'all'`: every entity regardless of tier.\n * - `Array<'external' | 'base'>`: an explicit combination. An empty array is honoured literally: nothing counts, so the limits can never trip.\n */\nexport type ApplyLimitsTo = 'external' | 'base' | 'all' | Array<'external' | 'base'>;\n\n/**\n * @description Ceilings on what a single document's entity references may cost. Both are cumulative across {@link EntityDecoder.decode} calls until\n * {@link EntityDecoder.reset}, and both default to `0`, meaning unlimited. `0`, and any negative or non-numeric value, is unlimited, because the\n * runtime tests `> 0` rather than truthiness of the configured number.\n */\nexport interface EntityDecoderLimitOptions {\n /**\n * @description Maximum number of tracked entity references expanded per document. The check is `> maxTotalExpansions`, so a limit of `2` allows two expansions\n * and throws on the third.\n *\n * @default 0\n */\n maxTotalExpansions?: number;\n\n /**\n * @description Maximum number of characters _added_ by expansion per document. Only the surplus counts: a reference whose replacement is no longer than the\n * `&token;` it replaces contributes zero, and a shrinking one contributes nothing and cannot trip the limit.\n *\n * @default 0\n */\n maxExpandedLength?: number;\n\n /**\n * @description Which tiers count against both limits. Defaults to `'external'`, which keeps the built-in entities, including every numeric reference, from being\n * able to trip a limit on a document the caller already trusts.\n *\n * @default 'external'\n */\n applyLimitsTo?: ApplyLimitsTo;\n}\n\n/**\n * @description Policy for numeric character references. The three fields are flattened into numeric levels at construction so the decode loop never re-reads the\n * object.\n */\nexport interface EntityDecoderNCROptions {\n /**\n * @description The XML version whose codepoint restrictions apply. `1.0` prohibits the C0 controls U+0001 to U+001F other than tab, newline and carriage return;\n * `1.1` does not, since it permits them when written as references. Any value other than `1.1` is read as `1.0`.\n *\n * @default 1.0\n */\n xmlVersion?: 1.0 | 1.1;\n\n /**\n * @description The base action for every numeric reference. Codepoint ranges that carry a minimum (surrogates always, the XML 1.0 C0 controls under `1.0`, and\n * null under `nullNCR`) take the stricter of the two, so this is a floor and not an override.\n *\n * @default 'allow'\n */\n onNCR?: 'allow' | 'leave' | 'remove' | 'throw';\n\n /**\n * @description The action for U+0000. `'allow'` and `'leave'` are clamped up to `'remove'`, so a null reference is always at least deleted.\n *\n * @default 'remove'\n */\n nullNCR?: 'remove' | 'throw';\n}\n\n/**\n * @description Construction options for {@link EntityDecoder}. Every field is optional, and the defaults are the permissive ones.\n */\nexport interface EntityDecoderOptions {\n /**\n * @description Extra named entities merged into the `base` map alongside the five XML predefined ones. A string value is used directly; a `{ regex, val }` or `{\n * regx, val }` envelope is unwrapped to its `val`. Anything else (a number, `null`, a function, an envelope whose `val` is a function) is dropped,\n * leaving the name unresolvable rather than failing the construction. Upstream's documentation says a value containing `&` is skipped here, to\n * prevent recursive expansion. It is not: the code stores the value unchanged, and only {@link EntityDecoder.addExternalEntity} checks for `&`.\n * Preserved as-is; see the note on the class.\n *\n * @default null\n */\n namedEntities?: Record<string, string | { regex: RegExp; val: string | EntityValFn }> | null;\n\n /**\n * @description Called once on the finished string. Receives `(resolved, original)` and must return a string; return `original` to reject the expansion outright,\n * or a sanitised form of `resolved` to clean it. It is _not_ called for a string that never reaches the scanning loop: an empty string, a\n * non-string, or any string with no `&` in it. A caller relying on `postCheck` to sanitise therefore has to know that a string with no ampersand is\n * never inspected.\n *\n * @default null\n */\n postCheck?: ((resolved: string, original: string) => string) | null;\n\n /**\n * @description Whether numeric references expand at all. Turning it off leaves every one of them in the output verbatim, _except_ the codepoints that carry a\n * minimum action of `remove` or stricter, which are still handled, because that classification runs first. This is why the option is safe to rely\n * on.\n *\n * @default true\n */\n numericAllowed?: boolean;\n\n /**\n * @description Names to keep as literal `&name;` text, matched against the token with no `&` or `;`. Numeric references are matched as `#38` or `#x26`.\n *\n * @default [ ]\n */\n leave?: Array<string>;\n\n /**\n * @description Names to delete outright, matched the same way as {@link EntityDecoderOptions.leave}. A removed reference is charged to the `external` tier even\n * when the name is a built-in one, so a document full of removed built-ins can trip an `applyLimitsTo: 'external'` limit it would not otherwise be\n * subject to. Preserved as-is; the only in-code comment claims the charge is for unknown references, which does not distinguish them.\n *\n * @default [ ]\n */\n remove?: Array<string>;\n\n /**\n * @description Ceilings on expansion count and expanded length. See {@link EntityDecoderLimitOptions}.\n */\n limit?: EntityDecoderLimitOptions;\n\n /**\n * @description Policy for numeric references. See {@link EntityDecoderNCROptions}.\n */\n ncr?: EntityDecoderNCROptions;\n\n /**\n * @description Called once per entity as it is registered through {@link EntityDecoder.setExternalEntities} or {@link EntityDecoder.addExternalEntity}. `block`\n * skips the entity, `throw` aborts the whole registration, anything else registers it. With {@link EntityDecoder.setExternalEntities} a `throw`\n * leaves the previous external map in place, because the replacement is only assigned once every entry has passed.\n *\n * @default null\n */\n onExternalEntity?: EntityRegistrationHook | null;\n\n /**\n * @description Called once per entity as it is registered through {@link EntityDecoder.addInputEntities}. Same contract as\n * {@link EntityDecoderOptions.onExternalEntity}, and unlike it the hook is not the only filter. See the class note on name validation.\n *\n * @default null\n */\n onInputEntity?: EntityRegistrationHook | null;\n}\n\n// ---------------------------------------------------------------------------\n// Internal types\n//\n// Looser than the exported option types on purpose. The runtime inspects whatever it is handed, and an entry it\n// cannot read is dropped rather than rejected, so the helpers have to be able to describe an entry the public\n// types claim cannot exist.\n// ---------------------------------------------------------------------------\n\n/**\n * @description The value side of a registration map as the merge helper reads it: a ready string, or a `{ regex | regx, val }` envelope whose `val` may itself be\n * absent, a string, or a function.\n */\ntype EntityInputValue =\n | string\n | { readonly regex?: RegExp | undefined; readonly regx?: RegExp | undefined; readonly val?: string | EntityValFn | undefined };\n\n/**\n * @description One registration map, or nothing. `null` and `undefined` both mean \"no entities here\", which is how `setExternalEntities(null)` clears the map and\n * how the constructor declines to pass `namedEntities`.\n */\ntype EntityInputMap = Readonly<Record<string, EntityInputValue>> | null | undefined;\n\n/**\n * @description What one reference expanded to, tagged with the tier its limit accounting charges. The field is the replacement text itself for a named entity, the\n * character for a numeric reference, and `''` for a removed one. Those are the three shapes the walk pushes into its output.\n */\ntype ResolvedEntity = { value: string; tier: LimitTier };\n\n/**\n * @description A registration context, as it appears in the error a rejecting hook produces.\n */\ntype HookContext = 'external' | 'input';\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * @description Reject an entity name that could never be written as a reference. `#` is refused positionally rather than by the character sweep, because a name\n * starting with `#` is a numeric reference's token and would collide with `#resolveNCR`. Everything else is refused per character.\n *\n * @param name - The name to check.\n *\n * @returns An effect producing the name, unchanged, so the call can be inlined. Fails with {@link XmlError} and the `InvalidEntityName` reason,\n * carrying the offending character. The `[EntityReplacer]` prefix in the message is preserved verbatim from the original throw, despite naming a\n * class this decoder does not have. It is relied on by anything matching the message text.\n */\nconst checkEntityName = (name: string): Effect.Effect<string, XmlError> => {\n if (name.charCodeAt(0) === CODE_HASH) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'InvalidEntityName', name, character: '#' },\n message: `[EntityReplacer] Invalid character '#' in entity name: \"${name}\"`,\n })\n );\n }\n for (const ch of name) {\n if (SPECIAL_CHARS.has(ch)) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'InvalidEntityName', name, character: ch },\n message: `[EntityReplacer] Invalid character '${ch}' in entity name: \"${name}\"`,\n })\n );\n }\n }\n return Effect.succeed(name);\n};\n\n/**\n * @description Flatten registration maps into one name to string map, later maps winning over earlier ones for the same name. The result is a null-prototype\n * object, not a `Map`. That is intentional. A `Map` iterates in pure insertion order, while `Object.keys` lifts array-index-like names to the front\n * in numeric order, and the registration hooks observe that order. A name of `\"2\"` registered after `\"brand\"` reaches the hook first here and second\n * in a `Map`.\n *\n * @param maps - The maps to merge. A falsy entry (`null`, `undefined`, `''`, `0`) contributes nothing rather than throwing.\n *\n * @returns A null-prototype object of own string-valued entries. Nothing from `Object.prototype` can be read out of it, so a document naming\n * `constructor` or `toString` finds nothing. Each entry is read through {@link flattenEntityValue}, so an entry that cannot be reduced to a string\n * is absent rather than present-and-unusable.\n */\nfunction mergeEntityMaps(...maps: ReadonlyArray<EntityInputMap>): Record<string, string> {\n const out: Record<string, string> = Object.create(null);\n for (const map of maps) {\n if (!map) continue;\n for (const key of Object.keys(map)) {\n const value = flattenEntityValue(map[key]);\n if (value !== undefined) out[key] = value;\n }\n }\n return out;\n}\n\n/**\n * @description Reduce one registration entry to the string a reference to it expands to, or to nothing when the entry is a form the scanner has no use for. Three\n * shapes survive: the string itself, and a `{ regex | regx, val }` envelope whose `val` is a string. Everything else (a number, `null`, `undefined`,\n * a bare function, an envelope whose `val` is a function) has no string to substitute, so the name is dropped and a reference to it comes back out as\n * the text it was written as. Dropping is silent on purpose: the runtime inspects whatever it is handed, and failing the construction over one\n * unreadable entry would take every other entity in the table down with it.\n *\n * @param raw - The entry as it arrived, in whatever shape the caller supplied it, including no entry at all, which a table with a hole in it\n * produces.\n *\n * @returns The replacement string, or `undefined` when the entry cannot be read. A name registered to the empty string yields `''`. That is why\n * callers compare against `undefined` rather than testing for emptiness.\n */\nfunction flattenEntityValue(raw: EntityInputValue | undefined): string | undefined {\n if (Predicate.isString(raw)) return raw;\n\n // The `raw &&` in upstream is a null check: every object is truthy, so it only ever rejects\n // `null` and `undefined` here, and the object check then rejects a bare function value.\n if (Predicate.isNullish(raw) || !Predicate.isObject(raw) || raw.val === undefined) return undefined;\n\n const val = raw.val;\n // A function `val` has no scanner equivalent and is dropped, upstream included.\n return Predicate.isString(val) ? val : undefined;\n}\n\n/**\n * @description Read one own entry out of a null-prototype entity map.\n *\n * @param map - The map to read.\n * @param key - The entity name.\n *\n * @returns The registered string, or `undefined` when the name is not an own key. A name registered to the empty string returns `''`. That is why\n * callers must compare against `undefined` rather than test for emptiness.\n */\nfunction ownEntity(map: Readonly<Record<string, string>>, key: string): string | undefined {\n // Upstream tests `name in map`, which reads as \"is this name present at all\". `Object.hasOwn` is\n // the same question asked explicitly, and it is the right shape for the answer: an absent key\n // yields `undefined` and a present one yields the stored string, so the `string | undefined` this\n // returns is the real type rather than something an assertion has to paper over. The two maps are\n // null-prototype objects, so `in` and `hasOwn` cannot disagree here.\n if (!Object.hasOwn(map, key)) return undefined;\n return map[key];\n}\n\n/**\n * @description Normalise the `applyLimitsTo` option into the set of tiers that count against the limits.\n *\n * @param raw - The configured value.\n *\n * @returns The tier set. An unrecognised string falls back to `external` rather than to no filtering at all, so a typo cannot silently disable the\n * limits. An array is taken as given. An empty array therefore disables limit accounting entirely, while an empty string falls back to `external`.\n */\nfunction parseLimitTiers(raw: ApplyLimitsTo | undefined): ReadonlySet<LimitTier> {\n if (!raw || raw === LIMIT_TIER_EXTERNAL) return new Set([LIMIT_TIER_EXTERNAL]);\n if (raw === LIMIT_TIER_ALL) return new Set([LIMIT_TIER_ALL]);\n if (raw === LIMIT_TIER_BASE) return new Set([LIMIT_TIER_BASE]);\n if (Array.isArray(raw)) return new Set(raw);\n return new Set([LIMIT_TIER_EXTERNAL]);\n}\n\n/**\n * @description Read one level out of {@link NCR_LEVEL} by name.\n *\n * @param name - The configured action name, or nothing.\n * @param fallback - The level to use when the name is absent.\n *\n * @returns The level. A name the table does not carry also yields `fallback`, so a value outside the union degrades to the default action rather than\n * to `NaN`.\n */\nfunction ncrLevelOf(name: NcrLevelName | undefined, fallback: number): number {\n if (name === undefined) return fallback;\n return NCR_LEVEL[name] ?? fallback;\n}\n\n/**\n * @description Flatten the `ncr` option into the three numeric fields the decode loop reads, so nothing has to be re-derived per reference.\n *\n * @param ncr - The configured policy, or nothing.\n *\n * @returns The XML version, the base action level, and the null action level already clamped up to `remove`.\n */\nfunction parseNCRConfig(ncr: EntityDecoderNCROptions | undefined): { xmlVersion: XmlVersion; onLevel: number; nullLevel: number } {\n if (!ncr) {\n return { xmlVersion: 1.0, onLevel: NCR_LEVEL.allow, nullLevel: NCR_LEVEL.remove };\n }\n const xmlVersion: XmlVersion = ncr.xmlVersion === 1.1 ? 1.1 : 1.0;\n const onLevel = ncrLevelOf(ncr.onNCR, NCR_LEVEL.allow);\n // Null is never safe to emit, so anything weaker than `remove` is raised to it before it is stored.\n const nullLevel = Math.max(ncrLevelOf(ncr.nullNCR, NCR_LEVEL.remove), NCR_LEVEL.remove);\n return { xmlVersion, onLevel, nullLevel };\n}\n\n/**\n * @description Resolve {@link EntityDecoderOptions.postCheck} to something the decode loop can call unconditionally, or to the identity function. The fallback\n * exists so the path that actually scanned does not have to test for the option, while the two fast paths that return before the scan still skip the\n * call entirely. A non-function value is treated as an absent option rather than rejected, matching the hook rules: a mistyped option disables its\n * feature instead of failing the construction.\n *\n * @param raw - The configured hook, or nothing.\n *\n * @returns The hook itself, or a function returning its first argument.\n */\nfunction readPostCheck(raw: EntityDecoderOptions['postCheck']): (resolved: string, original: string) => string {\n if (Predicate.isFunction(raw)) return raw;\n return r => r;\n}\n\n/**\n * @description Resolve a registration hook option to something safe to call, under the same non-function rule as {@link readPostCheck}.\n *\n * @param raw - The configured hook, or nothing.\n *\n * @returns The hook itself, or `null` for an absent option and for a value that is not a function. `null` lets every registration path ask\n * unconditionally: a hook that is not there accepts.\n */\nfunction readHook(raw: EntityRegistrationHook | null | undefined): EntityRegistrationHook | null {\n if (Predicate.isFunction(raw)) return raw;\n return null;\n}\n\n/**\n * @description Read one of the two entity-name lists as a set, under the same missing-value rule as the other options: absent is empty, not an error. The\n * `Array.isArray` test rather than a truthiness one keeps a mistyped list from reaching `new Set` and throwing there, so a caller's typo disables the\n * list instead of taking the decoder down. Matching against a set also means a name in both lists is decided by the order {@link EntityDecoder.decode}\n * consults them in, not by the order the caller wrote them in.\n *\n * @param raw - The configured list, or nothing.\n *\n * @returns The names as a set, empty when the option is absent or is not an array.\n */\nfunction readNameList(raw: Array<string> | undefined): ReadonlySet<string> {\n if (Array.isArray(raw)) return new Set(raw);\n return new Set();\n}\n\n/**\n * @description Scan forward from a `&` for the `;` that would close the reference it opens, giving up once more than {@link MAX_TOKEN_LENGTH} characters have\n * passed since the `&`.\n *\n * @param str - The string being scanned.\n * @param ampersand - The index of the `&`.\n *\n * @returns The index of the closing `;`, or `-1` when the run holds none inside the window. A `;` one character past the `&` is returned rather than\n * refused: that is the empty token `&;`, and the caller decides it is not a reference.\n */\nfunction scanTokenEnd(str: string, ampersand: number): number {\n const len = str.length;\n let j = ampersand + 1;\n while (j < len && str.charCodeAt(j) !== CODE_SEMICOLON && j - ampersand <= MAX_TOKEN_LENGTH) j++;\n if (j >= len || str.charCodeAt(j) !== CODE_SEMICOLON) return -1;\n return j;\n}\n\n/**\n * @description Single-pass, zero-regex entity decoder for XML and HTML content.\n *\n * ### Entity lookup priority\n *\n * 1. **input / runtime**: injected per document through {@link EntityDecoder.addInputEntities}\n * 2. **persistent external**: set through {@link EntityDecoder.setExternalEntities} and {@link EntityDecoder.addExternalEntity}, surviving\n * {@link EntityDecoder.reset}\n * 3. **base**: the five XML predefined entities plus the constructor's `namedEntities` Both input and external resolve as the `external` tier for limit\n * purposes, because both are injected at runtime. Numeric references (`&#NNN;`, `&#xHH;`) resolve directly through `String.fromCodePoint` and are\n * always `base` tier: they cannot recurse, so a limit that counted them would only punish a document that spells its characters out.\n *\n * ### Upstream behaviour preserved\n *\n * Several quirks of the original are kept intentionally, because a consumer's output already depends on them:\n *\n * - A value containing `&` is **not** filtered from `namedEntities` or `setExternalEntities`, contrary to the documentation. Only\n * {@link EntityDecoder.addExternalEntity} checks, and it drops the entry rather than storing it, so the same name registered either way can resolve\n * to nothing.\n * - {@link EntityDecoderOptions.postCheck} is skipped entirely for input that never reaches the scan: an empty string, a non-string, or a string with\n * no `&`.\n * - {@link EntityDecoder.decode} returns a non-string argument unchanged, despite being typed `string`.\n * - The expansion-limit errors are prefixed `EntityReplacer`, not `EntityDecoder`.\n * - Nothing in XML 1.0 §2.2 is enforced for U+007F to U+009F or for the U+FFFE/U+FFFF noncharacters, and the sweep for `&` leaves a name of\n * {@link MAX_TOKEN_LENGTH} + 1 characters unresolvable.\n * - Numeric references are parsed with `parseInt`, so a leading space, sign, or trailing garbage is accepted: `&# 41;`, `&#x+41;` and `)zz;` all\n * decode, and `�x41;` parses as a null reference rather than `A`.\n * - {@link EntityDecoder.addInputEntities} validates no names, so a `#`-prefixed or `&`-bearing name registers without complaint, where the two\n * external setters would throw.\n *\n * @example\n * ```typescript\n * const decoder = new EntityDecoder({ namedEntities: { copy: '©' } });\n * decoder.setExternalEntities({ brand: 'Acme' });\n * decoder.addInputEntities({ version: '1.0' });\n *\n * decoder.decode('&brand; v&version; ©'); // 'Acme v1.0 ©'\n * decoder.decode('&#38;'); // '&&', one pass, the output is never re-scanned\n *\n * decoder.reset(); // drops the input entities and the counters, keeps the external ones\n * ```;\n */\nexport class EntityDecoder {\n /**\n * @description {@link EntityDecoderLimitOptions.maxTotalExpansions}, or `0` for unlimited. A negative number or `NaN` is also unlimited, since the decode loop\n * tests `> 0`.\n */\n readonly #maxTotalExpansions: number;\n\n /**\n * @description {@link EntityDecoderLimitOptions.maxExpandedLength}, or `0` for unlimited, read the same way as `#maxTotalExpansions`.\n */\n readonly #maxExpandedLength: number;\n\n /**\n * @description {@link EntityDecoderOptions.postCheck}, or the identity function. That lets the decode loop call it unconditionally on the path that actually\n * scanned, and never on the two fast paths that return early.\n */\n readonly #postCheck: (resolved: string, original: string) => string;\n\n /**\n * @description The resolved tier filter. See {@link parseLimitTiers}.\n */\n readonly #limitTiers: ReadonlySet<LimitTier>;\n\n /**\n * @description {@link EntityDecoderOptions.numericAllowed}. Only an explicit `false` turns it off, so an absent option cannot disable it.\n */\n readonly #numericAllowed: boolean;\n\n /**\n * @description The five XML predefined entities plus `namedEntities`, merged once at construction and never written again. The built-ins lose to a\n * `namedEntities` entry of the same name, since it is merged second.\n */\n readonly #baseMap: Record<string, string>;\n\n /**\n * @description Persistent external entities, as a null-prototype object. Replaced wholesale by {@link EntityDecoder.setExternalEntities} and added to by\n * {@link EntityDecoder.addExternalEntity}, and never touched by {@link EntityDecoder.reset}. That is the whole distinction from the input map.\n */\n #externalMap: Record<string, string>;\n\n /**\n * @description DOCTYPE entities for the document being processed, as a null-prototype object. Wiped by both {@link EntityDecoder.reset} and\n * {@link EntityDecoder.addInputEntities}.\n */\n #inputMap: Record<string, string>;\n\n /**\n * @description Tracked expansions since the last reset. Cumulative across {@link EntityDecoder.decode} calls, which makes a limit a per-document budget rather\n * than a per-call one. Intentionally not reset by a thrown limit error, so the error message reports the over-limit count.\n */\n #totalExpansions: number;\n\n /**\n * @description Characters _added_ by expansion since the last reset, accumulated the same way as `#totalExpansions`. Only positive contributions are counted.\n */\n #expandedLength: number;\n\n /**\n * @description {@link EntityDecoderOptions.remove} as a set, or empty. Checked before every other classification, so a name in here is deleted without the name\n * ever being resolved.\n */\n readonly #removeSet: ReadonlySet<string>;\n\n /**\n * @description {@link EntityDecoderOptions.leave} as a set, or empty. Checked after `remove` and before the numeric test, so a name in here is emitted as the\n * original `&name;` text.\n */\n readonly #leaveSet: ReadonlySet<string>;\n\n /**\n * @description The XML version governing numeric classification. Mutable, because a `<?xml version?>` declaration is normally only known after the decoder\n * exists; see {@link EntityDecoder.setXmlVersion}.\n */\n #ncrXmlVersion: XmlVersion;\n\n /**\n * @description {@link EntityDecoderNCROptions.onNCR} as a level from {@link NCR_LEVEL}. A floor, not an override: the resolver takes the maximum of this and\n * whatever minimum a codepoint range imposes.\n */\n readonly #ncrOnLevel: number;\n\n /**\n * @description {@link EntityDecoderNCROptions.nullNCR} as a level from {@link NCR_LEVEL}, already clamped to `remove` or stricter.\n */\n readonly #ncrNullLevel: number;\n\n /**\n * @description {@link EntityDecoderOptions.onExternalEntity}, or `null` when absent or not a function. A non-function is dropped rather than rejected, so a\n * mistyped option disables the hook instead of failing the construction.\n */\n readonly #onExternalEntity: EntityRegistrationHook | null;\n\n /**\n * @description {@link EntityDecoderOptions.onInputEntity}, or `null`, under the same non-function rule as `#onExternalEntity`.\n */\n readonly #onInputEntity: EntityRegistrationHook | null;\n\n /**\n * @description Create a decoder. A factory rather than a constructor, because it refuses a `null` options object. Every field is optional, so `null` is not \"a\n * decoder with the defaults\". A caller who wrote it meant something the signature does not allow, and a decoder built from it would be\n * indistinguishable from one built from `{}` while hiding the mistake. Saying so is worth a factory; `EntityDecoderOptions` is a plain object and\n * nothing else about construction can fail.\n *\n * @example\n * ```typescript\n * const decoder = yield* EntityDecoder.make({ numericAllowed: false });\n * yield* decoder.decode('café'); // 'café'\n * ```;\n *\n * @param options - Configuration. See {@link EntityDecoderOptions}. Defaults to every field's own default.\n *\n * @returns An effect producing the decoder. Fails with {@link XmlError} and the `MissingOptions` reason for a `null`.\n */\n static make = (options: EntityDecoderOptions = {}): Effect.Effect<EntityDecoder, XmlError> =>\n Predicate.isNullish(options)\n ? Effect.fail(\n new XmlError({\n reason: { _tag: 'MissingOptions', parameter: 'options' },\n message: 'EntityDecoder.make: options is required. Use make({}) for a decoder with every default.',\n })\n )\n : Effect.succeed(new EntityDecoder(options));\n\n /**\n * @description Create a decoder. Every option is resolved here into the flat fields the decode loop reads, so nothing per-reference has to re-derive it. The\n * options whose wrong type disables them rather than failing the construction (the two hooks, the two name lists) are read through {@link readHook}\n * and {@link readNameList}, so that rule is written once instead of four times.\n *\n * @param resolved - Configuration, already checked. See {@link EntityDecoderOptions}.\n */\n private constructor(resolved: EntityDecoderOptions) {\n // `options.limit` is read first, intentionally: it is the first property the original touched, so\n // the property a `null` would have faulted on, and keeping that order means the reason still\n // names it. The option stays a local. Every value the decode loop needs is flattened out of it\n // below, so retaining it on the instance would only be a way to observe the option back.\n const limit = resolved.limit ?? {};\n this.#maxTotalExpansions = limit.maxTotalExpansions || 0;\n this.#maxExpandedLength = limit.maxExpandedLength || 0;\n this.#postCheck = readPostCheck(resolved.postCheck);\n this.#limitTiers = parseLimitTiers(limit.applyLimitsTo ?? LIMIT_TIER_EXTERNAL);\n this.#numericAllowed = resolved.numericAllowed ?? true;\n this.#baseMap = mergeEntityMaps(DEFAULT_XML_ENTITIES, resolved.namedEntities || null);\n\n this.#externalMap = Object.create(null);\n this.#inputMap = Object.create(null);\n this.#totalExpansions = 0;\n this.#expandedLength = 0;\n\n this.#removeSet = readNameList(resolved.remove);\n this.#leaveSet = readNameList(resolved.leave);\n\n const ncrConfig = parseNCRConfig(resolved.ncr);\n this.#ncrXmlVersion = ncrConfig.xmlVersion;\n this.#ncrOnLevel = ncrConfig.onLevel;\n this.#ncrNullLevel = ncrConfig.nullLevel;\n\n this.#onExternalEntity = readHook(resolved.onExternalEntity);\n this.#onInputEntity = readHook(resolved.onInputEntity);\n }\n\n /**\n * @description Ask a registration hook about one name and value.\n *\n * @param hook - The hook, or `null`. A `null` hook accepts, so {@link EntityDecoder.addExternalEntity} can call this unconditionally.\n * @param name - The entity name, without `&` or `;`.\n * @param value - The resolved value, after any `{ regex, val }` envelope was unwrapped.\n * @param context - Which registration is in progress, for the error message.\n *\n * @returns An effect producing `true` to register, `false` to skip silently. Fails with {@link XmlError} and the `EntityRejected` reason when the\n * hook returns `throw`. The message quotes the entity, so it is the only record left that a document was rejected.\n */\n #applyRegistrationHook(hook: EntityRegistrationHook | null, name: string, value: string, context: HookContext): Effect.Effect<boolean, XmlError> {\n if (!hook) return Effect.succeed(true); // nothing to ask\n const action = hook(name, value);\n if (action === ENTITY_ACTION.BLOCK) return Effect.succeed(false);\n if (action === ENTITY_ACTION.THROW) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'EntityRejected', context, name },\n message: `[EntityDecoder] Registration of ${context} entity \"&${name};\" was rejected by hook`,\n })\n );\n }\n return Effect.succeed(true); // ALLOW, and anything unrecognised, accepts\n }\n\n /**\n * @description Replace the whole set of persistent external entities. Every key is validated _before_ any value is read, so an invalid name fails even when its\n * value is a form the merge would have dropped. A non-object or `null` map clears the set without validating anything.\n *\n * @param map - The entities to register, or nothing to clear.\n *\n * @returns An effect that registers the map. Fails with {@link XmlError} when a key contains a character from {@link SPECIAL_CHARS} or begins with\n * `#` (`InvalidEntityName`), or when {@link EntityDecoderOptions.onExternalEntity} returns `throw` (`EntityRejected`). A rejection from the hook\n * aborts before the assignment, so the previous map survives.\n */\n setExternalEntities = Effect.fnUntraced(function* (\n this: EntityDecoder,\n map: Record<string, string | { regex: RegExp; val: string | EntityValFn }>\n ): Effect.fn.Return<void, XmlError> {\n if (map) {\n for (const key of Object.keys(map)) {\n yield* checkEntityName(key);\n }\n }\n if (!this.#onExternalEntity) {\n this.#externalMap = mergeEntityMaps(map);\n return;\n }\n // With a hook, values are flattened first and the hook sees what will actually be stored.\n const flat = mergeEntityMaps(map);\n const filtered: Record<string, string> = Object.create(null);\n for (const [name, value] of Object.entries(flat)) {\n if (yield* this.#applyRegistrationHook(this.#onExternalEntity, name, value, 'external')) {\n filtered[name] = value;\n }\n }\n this.#externalMap = filtered;\n });\n\n /**\n * @description Add one persistent external entity, keeping whatever is already registered. This is the only registration path that refuses a value containing\n * `&`; the two map setters store one unchanged. The omission is upstream's, and it is kept: the same name registered through either route can end\n * up resolving, or not resolving at all.\n *\n * @param key - The entity name, without `&` or `;`.\n * @param value - The replacement text.\n *\n * @returns An effect that adds the entity. Fails with {@link XmlError} and the `InvalidEntityName` reason when `key` contains a character from\n * {@link SPECIAL_CHARS} or begins with `#`, or with the `EntityRejected` reason when {@link EntityDecoderOptions.onExternalEntity} returns\n * `throw`.\n */\n addExternalEntity = Effect.fnUntraced(function* (this: EntityDecoder, key: string, value: string): Effect.fn.Return<void, XmlError> {\n yield* checkEntityName(key);\n // The two guards are unreachable from typed code (`value` is a `string`) and are kept for\n // untyped callers, which is the only way to reach them.\n if (Predicate.isString(value) && value.indexOf('&') === -1) {\n if (yield* this.#applyRegistrationHook(this.#onExternalEntity, key, value, 'external')) {\n this.#externalMap[key] = value;\n }\n }\n });\n\n /**\n * @description Register the DOCTYPE entities for the document about to be decoded, replacing any previous set and clearing both counters. Unlike the external\n * setters, no name is validated: a `#`-prefixed name, or one containing `&` or `<`, registers without complaint. A `#`-prefixed name is then\n * unreachable, since `decode` routes `#`-prefixed tokens to the numeric pipeline first.\n *\n * @param map - The entities to register, or nothing to clear.\n *\n * @returns An effect that registers the map. Fails with {@link XmlError} and the `EntityRejected` reason when\n * {@link EntityDecoderOptions.onInputEntity} returns `throw`. The counters have already been cleared by then.\n */\n addInputEntities = Effect.fnUntraced(function* (\n this: EntityDecoder,\n map: Record<string, string | { regx: RegExp; val: string | EntityValFn } | { regex: RegExp; val: string | EntityValFn }>\n ): Effect.fn.Return<void, XmlError> {\n // Cleared first and unconditionally, so registering entities is itself the start of a new\n // document's budget, including when the call goes on to fail.\n this.#totalExpansions = 0;\n this.#expandedLength = 0;\n if (!this.#onInputEntity) {\n this.#inputMap = mergeEntityMaps(map);\n return;\n }\n const flat = mergeEntityMaps(map);\n const filtered: Record<string, string> = Object.create(null);\n for (const [name, value] of Object.entries(flat)) {\n if (yield* this.#applyRegistrationHook(this.#onInputEntity, name, value, 'input')) {\n filtered[name] = value;\n }\n }\n this.#inputMap = filtered;\n });\n\n /**\n * @description Start a new document: drop the input entities and both counters. The persistent external entities, the base map, the limits, the NCR policy and\n * the XML version all survive, which is the difference between this and constructing a fresh decoder.\n *\n * @returns This decoder, so a call can be chained onto the document it ends.\n */\n reset(): this {\n this.#inputMap = Object.create(null);\n this.#totalExpansions = 0;\n this.#expandedLength = 0;\n return this;\n }\n\n /**\n * @description Set the XML version used to classify numeric references, once a `<?xml version=\"…\"?>` declaration has been read. Only the exact number `1.1`\n * selects XML 1.1; `1.0`, `1.15`, `'1.1'` and `NaN` all become `1.0`, so the stricter classification is the default rather than the looser one.\n *\n * @param version - The declared version.\n *\n * @returns Nothing.\n */\n setXmlVersion(version: number): void {\n this.#ncrXmlVersion = version === 1.1 ? 1.1 : 1.0;\n }\n\n /**\n * @description Expand every entity reference in a string, in one pass. The output is never re-scanned, so no expansion can produce a _second_ one: a registered\n * value that itself contains reference text reaches the caller as that literal text, unexpanded. What the limits bound is the growth of this single\n * pass, meaning how much one round of expansion can add. Three inputs return before the scan and therefore never reach\n * {@link EntityDecoderOptions.postCheck}: a non-string, the empty string, and any string with no `&` in it. The scan itself is `#expandAll`; what\n * this method adds is the three inputs that skip it and the single join of what it collected.\n *\n * @example\n * ```typescript\n * import { Effect } from 'effect';\n * import { EntityDecoder } from '@endevops/effect-codec-xml';\n *\n * const decoder = new EntityDecoder({ namedEntities: { copy: '©' } });\n * Effect.runSync(Effect.orElseSucceed(decoder.addExternalEntity('brand', 'Acme'), () => undefined));\n * Effect.runSync(decoder.decode('&brand; ©')); // 'Acme ©'\n * ```;\n *\n * @param str - The string to decode.\n *\n * @returns An effect producing the decoded string. A non-string argument comes back as the same non-string, which the `string` return type does not\n * describe but callers passing untyped values depend on. Fails with {@link XmlError} when a numeric reference is prohibited under the configured\n * policy (`ProhibitedCharacterReference`), or when a tracked tier would exceed {@link EntityDecoderLimitOptions.maxTotalExpansions}\n * (`ExpansionLimitExceeded`) or {@link EntityDecoderLimitOptions.maxExpandedLength} (`ExpandedLengthLimitExceeded`). The two limit messages keep\n * the `EntityReplacer` prefix from the original throw, which named a class this decoder does not have.\n */\n decode = Effect.fnUntraced(function* (this: EntityDecoder, str: string): Effect.fn.Return<string, XmlError> {\n if (!Predicate.isString(str) || str.length === 0) return str;\n if (str.indexOf('&') === -1) return str; // nothing here can be a reference\n\n const chunks = yield* this.#expandAll(str);\n\n // `chunks` is empty exactly when nothing was replaced, in which case the input is its own result.\n const result = chunks.length === 0 ? str : chunks.join('');\n\n return this.#postCheck(result, str);\n });\n\n /**\n * @description Walk the string once and collect the pieces of every reference that resolved. Two advance rules make the walk terminate and keep it correct: an\n * `&` that turns out to open nothing moves the cursor by one character rather than to the end of its run, so a second `&` in the same text is still\n * found; and a reference that did resolve moves it to just past the `;`, so the text that was substituted for it is never looked at again. The pass\n * stays single because of that, and a registered value containing `&` cannot expand a second level. What a reference becomes is `#resolveToken`'s\n * to decide and what it costs is `#chargeExpansion`'s to apply, which leaves the scanning here as the only thing with a rule of its own.\n *\n * @param str - The string to expand. It always holds at least one `&` and is never empty, or the caller would have returned before reaching the\n * walk.\n *\n * @returns An effect producing the pieces in order. The array is empty exactly when nothing was replaced, which the caller reads as \"the input is\n * its own result\". Fails with {@link XmlError} and the reason the offending reference carries: `ProhibitedCharacterReference`,\n * `ExpansionLimitExceeded` or `ExpandedLengthLimitExceeded`.\n */\n #expandAll = Effect.fnUntraced(function* (this: EntityDecoder, str: string): Effect.fn.Return<Array<string>, XmlError> {\n const chunks: Array<string> = [];\n const len = str.length;\n let last = 0; // start of the next unprocessed literal run\n let i = 0;\n\n while (i < len) {\n if (str.charCodeAt(i) !== CODE_AMPERSAND) {\n i++;\n continue;\n }\n\n const end = scanTokenEnd(str, i);\n if (end <= i + 1) {\n // Nothing to resolve: no `;` inside the scan window, or an empty token (`&;`). A bare ampersand\n // rather than a reference, so advance past the `&` only and let the rest of the run be copied.\n i++;\n continue;\n }\n\n const token = str.slice(i + 1, end);\n const resolved = yield* this.#resolveToken(token);\n if (resolved === undefined) {\n // Left, unparseable or unknown: leave the text alone and resume scanning just after the `&`.\n i++;\n continue;\n }\n\n if (i > last) chunks.push(str.slice(last, i));\n chunks.push(resolved.value);\n last = end + 1;\n i = last;\n\n yield* this.#chargeExpansion(token, resolved.value, resolved.tier);\n }\n\n if (last < len) chunks.push(str.slice(last));\n\n return chunks;\n });\n\n /**\n * @description Decide what one reference expands to. The lists and maps are consulted in the one order the runtime uses, and the first that matches wins:\n *\n * 1. `remove`: deleted outright, without the name ever being resolved, so the name need not exist.\n * 2. `leave`: emitted as the original `&token;`, and charged to nothing.\n * 3. A `#`-prefixed token: the numeric pipeline, which is the only one of the four that can fail. Classification runs before any decision about\n * `numericAllowed`, because the ranges that carry a minimum have to be caught whichever way that option is set.\n * 4. Anything else: resolved against the input map, then the external map, then the base map.\n *\n * @param token - The reference's token, e.g. `brand` or `#38`, with the `&` and the `;` already stripped. Never empty: the scanner drops `&;`\n * before calling.\n *\n * @returns An effect producing what the reference expands to and the tier to charge it to, or `undefined` to leave it as written and charge it\n * nothing. `undefined` covers all three ways of leaving a reference alone (a listed `leave` name, a numeric reference that is out of range, and a\n * name registered nowhere), and none of them is distinguishable from outside. Fails with {@link XmlError} and the `ProhibitedCharacterReference`\n * reason when the numeric policy throws on the codepoint.\n */\n #resolveToken = Effect.fnUntraced(function* (this: EntityDecoder, token: string): Effect.fn.Return<ResolvedEntity | undefined, XmlError> {\n if (this.#removeSet.has(token)) {\n // Deleted without being resolved, so the name need not exist. Upstream guards this charge with\n // `if (tier === undefined)`, and its `tier` is declared without an initialiser, so the branch is\n // unconditionally taken and the charge always lands on `external`, whatever tier the name would\n // have resolved in. That is why a document full of removed built-ins can trip an `external` limit\n // nothing it wrote could otherwise reach. Kept as written, since that is a behaviour a caller may\n // already be relying on.\n return { value: '', tier: LIMIT_TIER_EXTERNAL };\n }\n\n // Emitted as the original `&token;`. The walk advances only past the `&` and leaves the `;` to be\n // copied by the next literal run, which keeps the text coming back unchanged.\n if (this.#leaveSet.has(token)) return undefined;\n\n if (token.charCodeAt(0) === CODE_HASH) {\n const character = yield* this.#resolveNCR(token);\n // `''` for a removal and the character for an allow are both real replacements; `undefined` is the\n // numeric pipeline's own way of saying \"leave it as written\".\n if (character === undefined) return undefined;\n return { value: character, tier: LIMIT_TIER_BASE };\n }\n\n return this.#resolveName(token);\n });\n\n /**\n * @description Charge one expansion against the ceilings, or against neither. An expansion counts only when its tier passes `#tierCounts` and at least one\n * ceiling is configured, so a decoder with no limits set does no accounting at all, and an entity in a tier the filter excludes is free. Each\n * ceiling is guarded separately rather than left to its own check, because an unconfigured ceiling is not a ceiling of zero: `maxExpandedLength: 0`\n * means unlimited, so a decoder with only a count limit must not accumulate length it will then be compared against.\n *\n * @param token - The reference's token, with the `&` and `;` stripped. Its width is the baseline the expansion is measured against.\n * @param replacement - What the reference expanded to, including `''` for a removal.\n * @param tier - The tier the expansion is charged to.\n *\n * @returns An effect that fails with {@link XmlError} once a ceiling is exceeded, and succeeds otherwise. The count is checked before the length,\n * so a document that breaches both is reported against the count.\n */\n #chargeExpansion = Effect.fnUntraced(function* (\n this: EntityDecoder,\n token: string,\n replacement: string,\n tier: LimitTier\n ): Effect.fn.Return<void, XmlError> {\n const counts = this.#maxTotalExpansions > 0;\n const grows = this.#maxExpandedLength > 0;\n if (!counts && !grows) return;\n if (!this.#tierCounts(tier)) return;\n\n if (counts) yield* this.#countExpansion();\n if (grows) yield* this.#countExpandedLength(token, replacement);\n });\n\n /**\n * @description Add one expansion to the running total and compare it against {@link EntityDecoderLimitOptions.maxTotalExpansions}. The comparison is `>` rather\n * than `>=`, so a limit of `n` allows exactly `n` expansions and throws on the `n + 1`th. That is a contract, and the option's own documentation\n * states it. It is also the kind of off-by-one a tidy-up changes by accident. The counter is intentionally not reset before failing: the over-limit\n * total reports the over-limit total, and {@link EntityDecoder.reset} is the caller's way to start a new document.\n *\n * @returns An effect that fails with {@link XmlError} and the `ExpansionLimitExceeded` reason once the count is past the ceiling, and succeeds\n * otherwise. The `EntityReplacer` prefix in the message is preserved verbatim from the original throw, despite naming a class this decoder does\n * not have.\n */\n #countExpansion(): Effect.Effect<void, XmlError> {\n this.#totalExpansions++;\n if (this.#totalExpansions > this.#maxTotalExpansions) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'ExpansionLimitExceeded', actual: this.#totalExpansions, limit: this.#maxTotalExpansions },\n message: `[EntityReplacer] Entity expansion count limit exceeded: ${this.#totalExpansions} > ${this.#maxTotalExpansions}`,\n })\n );\n }\n return Effect.void;\n }\n\n /**\n * @description Add one expansion's surplus to the running total and compare it against {@link EntityDecoderLimitOptions.maxExpandedLength}. Only the surplus\n * counts, and only upward: a reference whose replacement is no longer than the `&token;` it replaces contributes zero, and a shrinking one\n * contributes nothing and cannot trip the limit at all. That keeps the ceiling a bound on growth rather than on document size.\n *\n * @param token - The reference's token, with the `&` and `;` stripped. The two delimiters count towards what the expansion displaced.\n * @param replacement - What the reference expanded to, including `''` for a removal.\n *\n * @returns An effect that fails with {@link XmlError} and the `ExpandedLengthLimitExceeded` reason once the total is past the ceiling, and succeeds\n * otherwise. The `EntityReplacer` prefix in the message is preserved verbatim from the original throw, for the same reason as in\n * `#countExpansion`.\n */\n #countExpandedLength(token: string, replacement: string): Effect.Effect<void, XmlError> {\n const delta = replacement.length - (token.length + 2);\n if (delta <= 0) return Effect.void;\n\n this.#expandedLength += delta;\n if (this.#expandedLength > this.#maxExpandedLength) {\n return Effect.fail(\n new XmlError({\n reason: { _tag: 'ExpandedLengthLimitExceeded', actual: this.#expandedLength, limit: this.#maxExpandedLength },\n message: `[EntityReplacer] Expanded content length limit exceeded: ${this.#expandedLength} > ${this.#maxExpandedLength}`,\n })\n );\n }\n return Effect.void;\n }\n\n /**\n * @description Decide whether an entity of a given tier is charged against the limits.\n *\n * @param tier - The tier the replacement is charged to. Every expansion that reaches here carries one (a name deleted before it was ever resolved\n * still carries the `external` tier), so there is no absent case to answer.\n *\n * @returns `true` when it counts. `'all'` short-circuits, so a filter naming every tier charges everything regardless of which map it came from.\n */\n #tierCounts(tier: LimitTier): boolean {\n if (this.#limitTiers.has(LIMIT_TIER_ALL)) return true;\n return this.#limitTiers.has(tier);\n }\n\n /**\n * @description Resolve a named entity token, with the `&` and `;` already stripped.\n *\n * @param name - The token, e.g. `brand`.\n *\n * @returns The value and the tier to charge it to, or `undefined` when the name is registered nowhere. A name registered to the empty string\n * resolves to `''` rather than to `undefined`, so it deletes the reference instead of leaving it alone.\n */\n #resolveName(name: string): ResolvedEntity | undefined {\n // Input and external share the `external` tier: both are injected at runtime, and that is the\n // surface the limits exist to bound.\n const fromInput = ownEntity(this.#inputMap, name);\n if (fromInput !== undefined) return { value: fromInput, tier: LIMIT_TIER_EXTERNAL };\n\n const fromExternal = ownEntity(this.#externalMap, name);\n if (fromExternal !== undefined) return { value: fromExternal, tier: LIMIT_TIER_EXTERNAL };\n\n const fromBase = ownEntity(this.#baseMap, name);\n if (fromBase !== undefined) return { value: fromBase, tier: LIMIT_TIER_BASE };\n\n return undefined;\n }\n\n /**\n * @description Find the strictest action a codepoint's range requires. Checked in this order:\n *\n * 1. U+0000: governed by `nullNCR`, already clamped to `remove` or stricter\n * 2. U+D800 to U+DFFF: surrogates, always `remove`, under every policy and both XML versions\n * 3. U+0001 to U+001F other than tab, newline, carriage return: XML 1.0 only, `remove` Nothing else is classified. U+007F to U+009F (C1) and the\n * U+FFFE/U+FFFF noncharacters are not checked, even though XML 1.0 §2.2 prohibits them and the `xmlVersion` option's own documentation claims C1\n * is only permitted under 1.1. Both gaps are upstream's and are kept.\n *\n * @param cp - The codepoint.\n *\n * @returns The minimum level from {@link NCR_LEVEL}, or {@link NO_MINIMUM_LEVEL} when the codepoint carries none.\n */\n #classifyNCR(cp: number): number {\n if (cp === 0) return this.#ncrNullLevel;\n\n if (cp >= 0xd800 && cp <= 0xdfff) return NCR_LEVEL.remove;\n\n if (this.#ncrXmlVersion === 1.0 && cp >= 0x01 && cp <= 0x1f && !XML10_ALLOWED_C0.has(cp)) {\n return NCR_LEVEL.remove;\n }\n\n return NO_MINIMUM_LEVEL;\n }\n\n /**\n * @description Turn a resolved action level into a replacement.\n *\n * @param action - A level from {@link NCR_LEVEL}. A level outside the four known ones falls through to the allow behaviour, so a bad level cannot\n * produce a wrong string. It can only fail open.\n * @param token - The raw token, e.g. `#38`, for the error message.\n * @param cp - The codepoint, for the error message.\n *\n * @returns An effect producing the character for `allow`, `''` for `remove`, and `undefined` for `leave`, which the caller reads as \"emit the\n * original `&token;`\". Fails with {@link XmlError} and the `ProhibitedCharacterReference` reason for `throw`, naming both the token and the\n * codepoint.\n */\n #applyNCRAction(action: number, token: string, cp: number): Effect.Effect<string | undefined, XmlError> {\n return Match.value(action).pipe(\n Match.when(NCR_LEVEL.allow, () => Effect.succeed(String.fromCodePoint(cp))),\n Match.when(NCR_LEVEL.remove, () => Effect.succeed('')),\n // oxlint-disable-next-line effecttsgo/effect-succeed-with-void\n Match.when(NCR_LEVEL.leave, () => Effect.succeed(undefined)),\n Match.when(NCR_LEVEL.throw, () =>\n Effect.fail(\n new XmlError({\n reason: { _tag: 'ProhibitedCharacterReference', token, codepoint: cp },\n message: `[EntityDecoder] Prohibited numeric character reference &${token}; ` + `(U+${cp.toString(16).toUpperCase().padStart(4, '0')})`,\n })\n )\n ),\n Match.orElse(() => Effect.succeed(String.fromCodePoint(cp)))\n );\n }\n\n /**\n * @description The full numeric-reference pipeline for one `#`-prefixed token.\n *\n * 1. Parse the codepoint, decimal or hex.\n * 2. Reject NaN, negatives, and anything above {@link MAX_CODE_POINT}, leaving the reference as written.\n * 3. Classify the codepoint for a minimum level.\n * 4. If `numericAllowed` is off and no minimum reaches `remove`, leave the reference as written.\n * 5. Take the stricter of the configured level and the minimum.\n * 6. Apply it. Step 4 is why `numericAllowed: false` does not neutralise `onNCR: 'throw'` for surrogates, the XML 1.0 C0 controls or null: their\n * minimum already reaches `remove`, so they are handled no matter what the option says. It does neutralise the throw for every other codepoint.\n * The parse is `parseInt`, which stops at the first character it cannot use. That is upstream's choice and it is permissive: a leading space or\n * `+`, and trailing garbage, are all accepted, and a decimal token beginning `0x` parses as `0` rather than as hex.\n *\n * @param token - The raw token without `&` and `;`, e.g. `#38`, `#x26`, `#X26`.\n *\n * @returns An effect producing the replacement (the empty string meaning \"delete\") or `undefined` to leave the reference as written. Fails with\n * {@link XmlError} and the `ProhibitedCharacterReference` reason when the effective action is `throw`.\n */\n #resolveNCR(token: string): Effect.Effect<string | undefined, XmlError> {\n const second = token.charCodeAt(1);\n let cp: number;\n if (second === CODE_LOWER_X || second === CODE_UPPER_X) {\n cp = parseInt(token.slice(2), 16);\n } else {\n cp = parseInt(token.slice(1), 10);\n }\n\n // Out of range is `leave` rather than `remove`: an unparseable reference is text, and\n // deleting a document's characters because one of them was malformed is not a safe default.\n if (Number.isNaN(cp) || cp < 0 || cp > MAX_CODE_POINT) return Effect.succeed(undefined);\n\n const minimum = this.#classifyNCR(cp);\n\n if (!this.#numericAllowed && minimum < NCR_LEVEL.remove) return Effect.succeed(undefined);\n\n const effective = minimum === NO_MINIMUM_LEVEL ? this.#ncrOnLevel : Math.max(this.#ncrOnLevel, minimum);\n\n return this.#applyNCRAction(effective, token, cp);\n }\n}\n"],"mappings":"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"}
|
package/dist/errors.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ declare const XmlParseError_base: Schema.Class<XmlParseError, Schema.TaggedStruc
|
|
|
20
20
|
*
|
|
21
21
|
* @example
|
|
22
22
|
* ```typescript
|
|
23
|
-
* import { XmlParseError } from '@endevops/effect-xml
|
|
23
|
+
* import { XmlParseError } from '@endevops/effect-codec-xml';
|
|
24
24
|
*
|
|
25
25
|
* const error = new XmlParseError({ message: 'Unclosed element', position: 12, input: '<a><b>' });
|
|
26
26
|
* ```;
|
|
@@ -39,7 +39,7 @@ declare const XmlRenderError_base: Schema.Class<XmlRenderError, Schema.TaggedStr
|
|
|
39
39
|
*
|
|
40
40
|
* @example
|
|
41
41
|
* ```typescript
|
|
42
|
-
* import { XmlRenderError } from '@endevops/effect-xml
|
|
42
|
+
* import { XmlRenderError } from '@endevops/effect-codec-xml';
|
|
43
43
|
*
|
|
44
44
|
* const error = new XmlRenderError({ message: 'Invalid XML name "not a name"' });
|
|
45
45
|
* ```;
|
package/dist/errors.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Schema } from "effect";
|
|
|
5
5
|
*
|
|
6
6
|
* @example
|
|
7
7
|
* ```typescript
|
|
8
|
-
* import { XmlParseError } from '@endevops/effect-xml
|
|
8
|
+
* import { XmlParseError } from '@endevops/effect-codec-xml';
|
|
9
9
|
*
|
|
10
10
|
* const error = new XmlParseError({ message: 'Unclosed element', position: 12, input: '<a><b>' });
|
|
11
11
|
* ```;
|
|
@@ -32,7 +32,7 @@ var XmlParseError = class extends Schema.TaggedError()("XmlParseError", {
|
|
|
32
32
|
*
|
|
33
33
|
* @example
|
|
34
34
|
* ```typescript
|
|
35
|
-
* import { XmlRenderError } from '@endevops/effect-xml
|
|
35
|
+
* import { XmlRenderError } from '@endevops/effect-codec-xml';
|
|
36
36
|
*
|
|
37
37
|
* const error = new XmlRenderError({ message: 'Invalid XML name "not a name"' });
|
|
38
38
|
* ```;
|
package/dist/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["// The one way XML serialization can fail that a `SchemaIssue.Issue` does not already describe.\n//\n// A schema mismatch
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["// The one way XML serialization can fail that a `SchemaIssue.Issue` does not already describe.\n//\n// A schema mismatch, such as a `number` where the document says `text`, is a\n// `SchemaIssue.Issue` and comes from Effect's own parser. What is left is the\n// part Effect knows nothing about: a document that is not well-formed XML, and a\n// field name that cannot be written as one.\n\nimport { Schema } from 'effect';\n\n/**\n * @description A document could not be read as XML.\n *\n * @example\n * ```typescript\n * import { XmlParseError } from '@endevops/effect-codec-xml';\n *\n * const error = new XmlParseError({ message: 'Unclosed element', position: 12, input: '<a><b>' });\n * ```;\n */\nexport class XmlParseError extends Schema.TaggedError<XmlParseError>()('XmlParseError', {\n /**\n * @description What was wrong with the document.\n */\n message: Schema.String,\n\n /**\n * @description Character offset into the source text where the problem was found. `-1` when the failure is not tied to a position, such as trailing content\n * after the root element.\n */\n position: Schema.Finite,\n\n /**\n * @description The source text that failed to parse, so a log can carry the document without the caller re-reading it.\n */\n input: Schema.String,\n}) {}\n\n/**\n * @description A value could not be written as XML. A field name that is not a legal XML name fails here, in `'error'` name mode; repair mode rewrites the name\n * instead. The depth cap fails here too, when a value nests past `maxDepth`. Reading uses {@link XmlParseError}, because the two directions fail for\n * different reasons and a caller recovering from one usually does not want to catch the other.\n *\n * @example\n * ```typescript\n * import { XmlRenderError } from '@endevops/effect-codec-xml';\n *\n * const error = new XmlRenderError({ message: 'Invalid XML name \"not a name\"' });\n * ```;\n */\nexport class XmlRenderError extends Schema.TaggedError<XmlRenderError>()('XmlRenderError', {\n /**\n * @description What was wrong with the value.\n */\n message: Schema.String,\n}) {}\n"],"mappings":";;;;;;;;;;;;AAmBA,IAAa,gBAAb,cAAmC,OAAO,YAA2B,CAAC,CAAC,iBAAiB;;;;CAItF,SAAS,OAAO;;;;;CAMhB,UAAU,OAAO;;;;CAKjB,OAAO,OAAO;AAChB,CAAC,CAAC,CAAC,CAAC;;;;;;;;;;;;;AAcJ,IAAa,iBAAb,cAAoC,OAAO,YAA4B,CAAC,CAAC,kBAAkB;;;;AAIzF,SAAS,OAAO,OAClB,CAAC,CAAC,CAAC,CAAC"}
|
package/dist/namespaces.js
CHANGED
|
@@ -402,7 +402,7 @@ const scanNode = (scan, ast, inherited, elementPath) => {
|
|
|
402
402
|
}
|
|
403
403
|
};
|
|
404
404
|
/**
|
|
405
|
-
* @description Collects the namespace and name of every field in a schema.
|
|
405
|
+
* @description Collects the namespace and name of every field in a schema. Descendant elements inherit a namespace, as they inherit a default namespace, and an
|
|
406
406
|
* element field records its own namespace, so encode and decode can find it by the local name alone.
|
|
407
407
|
*
|
|
408
408
|
* @param schema - The schema to walk.
|
|
@@ -611,7 +611,7 @@ const schemaKey = (plan, parent, key, scope) => {
|
|
|
611
611
|
/**
|
|
612
612
|
* @description The scope a child element resolves its own name against: the declarations it carries on itself, layered over the parent scope. An element may
|
|
613
613
|
* declare the prefix it uses on the element itself, so its own name is read with those bindings in scope. A repeated element arrives as an array, so
|
|
614
|
-
* the first member stands in for the run
|
|
614
|
+
* the first member stands in for the run; every member describes the same element and carries the same declaration.
|
|
615
615
|
*
|
|
616
616
|
* @param child - The child value.
|
|
617
617
|
* @param scope - The bindings in scope above the child.
|
|
@@ -623,29 +623,38 @@ const childScopeOf = (child, scope) => {
|
|
|
623
623
|
return Predicate.isObject(child) ? scopeOf(child, scope) : scope;
|
|
624
624
|
};
|
|
625
625
|
/**
|
|
626
|
-
* @description
|
|
627
|
-
*
|
|
628
|
-
*
|
|
626
|
+
* @description Character data read back as a value tree. An element the schema reads as a struct with a value field derives that field from the element's
|
|
627
|
+
* character data, and the parser reduces an element with no attributes and no children to a bare string, so the string is put back under the value's
|
|
628
|
+
* key for that struct to read.
|
|
629
629
|
*
|
|
630
|
-
* @param value - The
|
|
630
|
+
* @param value - The character data the parser produced.
|
|
631
|
+
* @param plan - The namespace plan.
|
|
632
|
+
* @param elementPath - The path of the element the character data belongs to.
|
|
633
|
+
*
|
|
634
|
+
* @returns The character data, under the element's value key when it has one.
|
|
635
|
+
*/
|
|
636
|
+
const decodeText = (value, plan, elementPath) => {
|
|
637
|
+
const valueKey = plan.valueByElement.get(elementPath);
|
|
638
|
+
return valueKey !== void 0 ? { [valueKey]: value } : value;
|
|
639
|
+
};
|
|
640
|
+
/**
|
|
641
|
+
* @description One record read back: its declarations dropped, its keys resolved to the schema's names, and its character data placed under the value field.
|
|
642
|
+
*
|
|
643
|
+
* @param record - The record to read.
|
|
631
644
|
* @param plan - The namespace plan.
|
|
632
645
|
* @param scope - The prefix bindings in scope above this element.
|
|
633
646
|
* @param elementPath - The path of this element, or the root sentinel.
|
|
634
647
|
*
|
|
635
|
-
* @returns The
|
|
648
|
+
* @returns The record, keyed by the schema's names, or the string it collapses to.
|
|
636
649
|
*/
|
|
637
|
-
const
|
|
638
|
-
if (Array.isArray(value)) return value.map((member) => decodeNames(member, plan, scope, elementPath));
|
|
639
|
-
if (Predicate.isString(value) || Predicate.isUndefined(value)) return value;
|
|
640
|
-
if (!Predicate.isObject(value)) return value;
|
|
641
|
-
const record = value;
|
|
650
|
+
const decodeRecord = (record, plan, scope, elementPath) => {
|
|
642
651
|
const inner = scopeOf(record, scope);
|
|
643
652
|
const valueKey = plan.valueByElement.get(elementPath);
|
|
644
653
|
const out = {};
|
|
645
654
|
for (const [key, child] of Object.entries(record)) {
|
|
646
655
|
if (isDeclarationKey(key)) continue;
|
|
647
656
|
if (key === "#text") {
|
|
648
|
-
out[valueKey ?? "#text"] =
|
|
657
|
+
out[valueKey ?? "#text"] = child;
|
|
649
658
|
continue;
|
|
650
659
|
}
|
|
651
660
|
const childKey = schemaKey(plan, elementPath, key, childScopeOf(child, inner));
|
|
@@ -657,6 +666,24 @@ const decodeNames = (value, plan, scope, elementPath) => {
|
|
|
657
666
|
if (keys.length === 1 && keys[0] === "#text") return out[TEXT_KEY];
|
|
658
667
|
return out;
|
|
659
668
|
};
|
|
669
|
+
/**
|
|
670
|
+
* @description Rewrites a wire value tree back to the schema's local names, resolving every name against the declarations the document carries and dropping those
|
|
671
|
+
* declarations. Character data maps to the value field of the element it belongs to. A record left holding only character data collapses back to that
|
|
672
|
+
* string, which is how a namespaced leaf stays a `Schema.String`.
|
|
673
|
+
*
|
|
674
|
+
* @param value - The parsed wire tree.
|
|
675
|
+
* @param plan - The namespace plan.
|
|
676
|
+
* @param scope - The prefix bindings in scope above this element.
|
|
677
|
+
* @param elementPath - The path of this element, or the root sentinel.
|
|
678
|
+
*
|
|
679
|
+
* @returns The value tree, keyed by the schema's names.
|
|
680
|
+
*/
|
|
681
|
+
const decodeNames = (value, plan, scope, elementPath) => {
|
|
682
|
+
if (Array.isArray(value)) return value.map((member) => decodeNames(member, plan, scope, elementPath));
|
|
683
|
+
if (Predicate.isString(value)) return decodeText(value, plan, elementPath);
|
|
684
|
+
if (!Predicate.isObject(value)) return value;
|
|
685
|
+
return decodeRecord(value, plan, scope, elementPath);
|
|
686
|
+
};
|
|
660
687
|
//#endregion
|
|
661
688
|
export { ATTRIBUTE_KEY, NAMESPACE_KEY, NAME_KEY, PREFIX_KEY, VALUE_KEY, decodeNames, encodeNames, namespacePlan };
|
|
662
689
|
|