| 1 | import * as Effect from "../../Effect.ts" |
| 2 | import { format } from "../../Formatter.ts" |
| 3 | import type * as Schema from "../../Schema.ts" |
| 4 | import * as SchemaParser from "../../SchemaParser.ts" |
| 5 | import * as InternalAnnotations from "./annotations.ts" |
| 6 | import * as InternalToCodec from "./toCodec.ts" |
| 7 | |
| 8 | interface XmlEncoderOptions { |
| 9 | readonly rootName?: string | undefined |
| 10 | readonly arrayItemName?: string | undefined |
| 11 | readonly pretty?: boolean | undefined |
| 12 | readonly indent?: string | undefined |
| 13 | readonly sortKeys?: boolean | undefined |
| 14 | } |
| 15 | |
| 16 | /** @internal */ |
| 17 | export function toEncoderXml<T, RE>( |
| 18 | codec: Schema.ConstraintCodec<T, unknown, unknown, RE>, |
| 19 | options?: XmlEncoderOptions |
| 20 | ) { |
| 21 | const rootName = InternalAnnotations.resolveIdentifier(codec.ast) ?? InternalAnnotations.resolveTitle(codec.ast) |
| 22 | const serialize = SchemaParser.encodeEffect(InternalToCodec.toCodecStringTree(codec)) |
| 23 | return (value: T) => |
| 24 | serialize(value).pipe(Effect.map((stringTree) => stringTreeToXml(stringTree, { rootName, ...options }))) |
| 25 | } |
| 26 | |
| 27 | function stringTreeToXml(value: Schema.StringTree, options: XmlEncoderOptions): string { |
| 28 | const rootName = options.rootName ?? "root" |
| 29 | const arrayItemName = options.arrayItemName ?? "item" |
| 30 | const pretty = options.pretty ?? true |
| 31 | const indent = options.indent ?? " " |
| 32 | const sortKeys = options.sortKeys ?? true |
| 33 | |
| 34 | const seen = new Set<object>() |
| 35 | const lines: Array<string> = [] |
| 36 | |
| 37 | recur(rootName, value, 0) |
| 38 | return lines.join(pretty ? "\n" : "") |
| 39 | |
| 40 | function push(depth: number, text: string): void { |
| 41 | lines.push(pretty ? indent.repeat(depth) + text : text) |
| 42 | } |
| 43 | |
| 44 | function recur(tagName: string, node: Schema.StringTree, depth: number, originalNameForMeta?: string): void { |
| 45 | const { attrs, safe } = xml.tagInfo(tagName, originalNameForMeta) |
| 46 | |
| 47 | if (node === undefined) { |
| 48 | push(depth, `<${safe}${attrs}/>`) |
| 49 | } else if (typeof node === "string") { |
| 50 | push(depth, `<${safe}${attrs}>${xml.escapeText(node)}</${safe}>`) |
| 51 | } else if (typeof node !== "object" || node === null) { |
| 52 | push(depth, `<${safe}${attrs}>${xml.escapeText(format(node))}</${safe}>`) |
| 53 | } else { |
| 54 | if (seen.has(node)) throw new globalThis.Error("Cycle detected while serializing to XML.", { cause: node }) |
| 55 | seen.add(node) |
| 56 | try { |
| 57 | if (globalThis.Array.isArray(node)) { |
| 58 | if (node.length === 0) { |
| 59 | push(depth, `<${safe}${attrs}/>`) |
| 60 | return |
| 61 | } |
| 62 | push(depth, `<${safe}${attrs}>`) |
| 63 | for (const item of node) recur(arrayItemName, item, depth + 1) |
| 64 | push(depth, `</${safe}>`) |
| 65 | return |
| 66 | } |
| 67 | |
| 68 | const object = node as Record<string, Schema.StringTree> |
| 69 | const keys = Object.keys(object) |
| 70 | if (sortKeys) keys.sort() |
| 71 | |
| 72 | if (keys.length === 0) { |
| 73 | push(depth, `<${safe}${attrs}/>`) |
| 74 | return |
| 75 | } |
| 76 | |
| 77 | push(depth, `<${safe}${attrs}>`) |
| 78 | for (const key of keys) { |
| 79 | recur(xml.parseTagName(key).safe, object[key], depth + 1, key) |
| 80 | } |
| 81 | push(depth, `</${safe}>`) |
| 82 | } finally { |
| 83 | seen.delete(node) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | const xml = { |
| 90 | escapeText(value: string): string { |
| 91 | return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") |
| 92 | }, |
| 93 | escapeAttribute(value: string): string { |
| 94 | return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;") |
| 95 | }, |
| 96 | parseTagName(name: string): { safe: string; changed: boolean } { |
| 97 | const original = name |
| 98 | let safe = name |
| 99 | if (!/^[A-Za-z_]/.test(safe)) safe = "_" + safe |
| 100 | safe = safe.replace(/[^A-Za-z0-9._-]/g, "_") |
| 101 | if (/^xml/i.test(safe)) safe = "_" + safe |
| 102 | return { safe, changed: safe !== original } |
| 103 | }, |
| 104 | tagInfo(name: string, original?: string): { safe: string; attrs: string } { |
| 105 | const { changed, safe } = xml.parseTagName(name) |
| 106 | const needsMeta = changed || (original && original !== name) |
| 107 | const attrs = needsMeta ? ` data-name="${xml.escapeAttribute(original ?? name)}"` : "" |
| 108 | return { safe, attrs } |
| 109 | } |
| 110 | } |