1import * as Effect from "../../Effect.ts"
2import { format } from "../../Formatter.ts"
3import type * as Schema from "../../Schema.ts"
4import * as SchemaParser from "../../SchemaParser.ts"
5import * as InternalAnnotations from "./annotations.ts"
6import * as InternalToCodec from "./toCodec.ts"
7
8interface 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 */
17export 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
27function 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
89const 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}