@navios/adapter-xml 0.1.0
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/LICENSE +7 -0
- package/README.md +532 -0
- package/bun-plugin.mts +129 -0
- package/bunPlugin.cache +1 -0
- package/bunfig.toml +3 -0
- package/dist/bun-plugin.d.mts +4 -0
- package/dist/bun-plugin.d.mts.map +1 -0
- package/dist/e2e/bun/xml-stream.spec.d.ts +2 -0
- package/dist/e2e/bun/xml-stream.spec.d.ts.map +1 -0
- package/dist/e2e/fastify/xml-stream.spec.d.ts +2 -0
- package/dist/e2e/fastify/xml-stream.spec.d.ts.map +1 -0
- package/dist/src/adapters/index.d.mts +2 -0
- package/dist/src/adapters/index.d.mts.map +1 -0
- package/dist/src/adapters/xml-stream-adapter.service.d.mts +21 -0
- package/dist/src/adapters/xml-stream-adapter.service.d.mts.map +1 -0
- package/dist/src/decorators/component.decorator.d.mts +17 -0
- package/dist/src/decorators/component.decorator.d.mts.map +1 -0
- package/dist/src/decorators/component.decorator.spec.d.mts +2 -0
- package/dist/src/decorators/component.decorator.spec.d.mts.map +1 -0
- package/dist/src/decorators/index.d.mts +4 -0
- package/dist/src/decorators/index.d.mts.map +1 -0
- package/dist/src/decorators/xml-stream.decorator.d.mts +42 -0
- package/dist/src/decorators/xml-stream.decorator.d.mts.map +1 -0
- package/dist/src/define-environment.d.mts +31 -0
- package/dist/src/define-environment.d.mts.map +1 -0
- package/dist/src/handlers/index.d.mts +2 -0
- package/dist/src/handlers/index.d.mts.map +1 -0
- package/dist/src/handlers/xml-stream.d.mts +23 -0
- package/dist/src/handlers/xml-stream.d.mts.map +1 -0
- package/dist/src/index.d.mts +12 -0
- package/dist/src/index.d.mts.map +1 -0
- package/dist/src/jsx-dev-runtime.d.mts +5 -0
- package/dist/src/jsx-dev-runtime.d.mts.map +1 -0
- package/dist/src/jsx-runtime.d.mts +3 -0
- package/dist/src/jsx-runtime.d.mts.map +1 -0
- package/dist/src/jsx.d.mts +18 -0
- package/dist/src/jsx.d.mts.map +1 -0
- package/dist/src/runtime/create-element.d.mts +25 -0
- package/dist/src/runtime/create-element.d.mts.map +1 -0
- package/dist/src/runtime/fragment.d.mts +2 -0
- package/dist/src/runtime/fragment.d.mts.map +1 -0
- package/dist/src/runtime/index.d.mts +5 -0
- package/dist/src/runtime/index.d.mts.map +1 -0
- package/dist/src/runtime/render-to-xml.d.mts +20 -0
- package/dist/src/runtime/render-to-xml.d.mts.map +1 -0
- package/dist/src/runtime/render-to-xml.spec.d.mts +2 -0
- package/dist/src/runtime/render-to-xml.spec.d.mts.map +1 -0
- package/dist/src/runtime/special-nodes.d.mts +24 -0
- package/dist/src/runtime/special-nodes.d.mts.map +1 -0
- package/dist/src/tags/define-tag.d.mts +33 -0
- package/dist/src/tags/define-tag.d.mts.map +1 -0
- package/dist/src/tags/define-tag.spec.d.mts +2 -0
- package/dist/src/tags/define-tag.spec.d.mts.map +1 -0
- package/dist/src/tags/index.d.mts +3 -0
- package/dist/src/tags/index.d.mts.map +1 -0
- package/dist/src/types/component.d.mts +15 -0
- package/dist/src/types/component.d.mts.map +1 -0
- package/dist/src/types/config.d.mts +10 -0
- package/dist/src/types/config.d.mts.map +1 -0
- package/dist/src/types/index.d.mts +5 -0
- package/dist/src/types/index.d.mts.map +1 -0
- package/dist/src/types/xml-node.d.mts +35 -0
- package/dist/src/types/xml-node.d.mts.map +1 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/tsconfig.spec.tsbuildinfo +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/tsup.config.d.mts +3 -0
- package/dist/tsup.config.d.mts.map +1 -0
- package/dist/vitest.config.d.mts +3 -0
- package/dist/vitest.config.d.mts.map +1 -0
- package/dist/vitest.e2e.fastify.config.d.mts +3 -0
- package/dist/vitest.e2e.fastify.config.d.mts.map +1 -0
- package/e2e/bun/xml-stream.spec.tsx +553 -0
- package/e2e/fastify/xml-stream.spec.tsx +569 -0
- package/jsx.d.ts +42 -0
- package/lib/_tsup-dts-rollup.d.mts +414 -0
- package/lib/_tsup-dts-rollup.d.ts +414 -0
- package/lib/chunk-6OR6LGJA.mjs +153 -0
- package/lib/chunk-6OR6LGJA.mjs.map +1 -0
- package/lib/index.d.mts +29 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.js +376 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +256 -0
- package/lib/index.mjs.map +1 -0
- package/lib/jsx-dev-runtime.d.mts +4 -0
- package/lib/jsx-dev-runtime.d.ts +4 -0
- package/lib/jsx-dev-runtime.js +61 -0
- package/lib/jsx-dev-runtime.js.map +1 -0
- package/lib/jsx-dev-runtime.mjs +9 -0
- package/lib/jsx-dev-runtime.mjs.map +1 -0
- package/lib/jsx-runtime.d.mts +3 -0
- package/lib/jsx-runtime.d.ts +3 -0
- package/lib/jsx-runtime.js +57 -0
- package/lib/jsx-runtime.js.map +1 -0
- package/lib/jsx-runtime.mjs +3 -0
- package/lib/jsx-runtime.mjs.map +1 -0
- package/lib/jsx.d.mts +1 -0
- package/lib/jsx.d.ts +1 -0
- package/lib/jsx.js +4 -0
- package/lib/jsx.js.map +1 -0
- package/lib/jsx.mjs +3 -0
- package/lib/jsx.mjs.map +1 -0
- package/package.json +80 -0
- package/project.json +91 -0
- package/src/adapters/index.mts +1 -0
- package/src/adapters/xml-stream-adapter.service.mts +121 -0
- package/src/decorators/component.decorator.mts +102 -0
- package/src/decorators/component.decorator.spec.mts +345 -0
- package/src/decorators/index.mts +4 -0
- package/src/decorators/xml-stream.decorator.mts +93 -0
- package/src/define-environment.mts +40 -0
- package/src/handlers/index.mts +1 -0
- package/src/handlers/xml-stream.mts +31 -0
- package/src/index.mts +41 -0
- package/src/jsx-dev-runtime.mts +8 -0
- package/src/jsx-runtime.mts +2 -0
- package/src/jsx.mts +25 -0
- package/src/runtime/create-element.mts +113 -0
- package/src/runtime/fragment.mts +1 -0
- package/src/runtime/index.mts +4 -0
- package/src/runtime/render-to-xml.mts +214 -0
- package/src/runtime/render-to-xml.spec.mts +360 -0
- package/src/runtime/special-nodes.mts +32 -0
- package/src/tags/define-tag.mts +54 -0
- package/src/tags/define-tag.spec.mts +250 -0
- package/src/tags/index.mts +2 -0
- package/src/types/component.mts +16 -0
- package/src/types/config.mts +15 -0
- package/src/types/index.mts +23 -0
- package/src/types/jsx.d.ts +21 -0
- package/src/types/xml-node.mts +50 -0
- package/tsconfig.json +24 -0
- package/tsconfig.lib.json +8 -0
- package/tsconfig.spec.json +25 -0
- package/tsup.config.mts +18 -0
- package/vitest.config.mts +9 -0
- package/vitest.e2e.fastify.config.mts +29 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { Fragment } from '../types/xml-node.mjs'
|
|
4
|
+
import { createElement } from './create-element.mjs'
|
|
5
|
+
import { renderToXml } from './render-to-xml.mjs'
|
|
6
|
+
import { CData, DangerouslyInsertRawXml } from './special-nodes.mjs'
|
|
7
|
+
|
|
8
|
+
describe('renderToXml', () => {
|
|
9
|
+
describe('basic elements', () => {
|
|
10
|
+
it('should render a simple element', async () => {
|
|
11
|
+
const node = createElement('item', null)
|
|
12
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
13
|
+
expect(xml).toBe('<item/>')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should render an element with text content', async () => {
|
|
17
|
+
const node = createElement('title', null, 'Hello World')
|
|
18
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
19
|
+
expect(xml).toBe('<title>Hello World</title>')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should render an element with attributes', async () => {
|
|
23
|
+
const node = createElement('link', {
|
|
24
|
+
href: 'https://example.com',
|
|
25
|
+
rel: 'self',
|
|
26
|
+
})
|
|
27
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
28
|
+
expect(xml).toBe('<link href="https://example.com" rel="self"/>')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should render nested elements', async () => {
|
|
32
|
+
const node = createElement(
|
|
33
|
+
'channel',
|
|
34
|
+
null,
|
|
35
|
+
createElement('title', null, 'My Feed'),
|
|
36
|
+
)
|
|
37
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
38
|
+
expect(xml).toBe('<channel><title>My Feed</title></channel>')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should render numeric content', async () => {
|
|
42
|
+
const node = createElement('count', null, 42)
|
|
43
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
44
|
+
expect(xml).toBe('<count>42</count>')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should skip null and undefined children', async () => {
|
|
48
|
+
const node = createElement(
|
|
49
|
+
'items',
|
|
50
|
+
null,
|
|
51
|
+
null,
|
|
52
|
+
createElement('item', null),
|
|
53
|
+
undefined,
|
|
54
|
+
)
|
|
55
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
56
|
+
expect(xml).toBe('<items><item/></items>')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('XML declaration', () => {
|
|
61
|
+
it('should include XML declaration by default', async () => {
|
|
62
|
+
const node = createElement('root', null)
|
|
63
|
+
const xml = await renderToXml(node)
|
|
64
|
+
expect(xml).toBe('<?xml version="1.0" encoding="UTF-8"?><root/>')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should omit XML declaration when disabled', async () => {
|
|
68
|
+
const node = createElement('root', null)
|
|
69
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
70
|
+
expect(xml).toBe('<root/>')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should use custom encoding', async () => {
|
|
74
|
+
const node = createElement('root', null)
|
|
75
|
+
const xml = await renderToXml(node, { encoding: 'ISO-8859-1' })
|
|
76
|
+
expect(xml).toBe('<?xml version="1.0" encoding="ISO-8859-1"?><root/>')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('XML escaping', () => {
|
|
81
|
+
it('should escape special characters in text content', async () => {
|
|
82
|
+
const node = createElement('text', null, 'Hello <world> & "friends"')
|
|
83
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
84
|
+
expect(xml).toBe('<text>Hello <world> & "friends"</text>')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should escape special characters in attributes', async () => {
|
|
88
|
+
const node = createElement('link', {
|
|
89
|
+
title: 'A "quoted" & <special> title',
|
|
90
|
+
})
|
|
91
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
92
|
+
expect(xml).toBe(
|
|
93
|
+
'<link title="A "quoted" & <special> title"/>',
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should skip null attributes', async () => {
|
|
98
|
+
const node = createElement('link', {
|
|
99
|
+
href: 'https://example.com',
|
|
100
|
+
title: null,
|
|
101
|
+
rel: undefined,
|
|
102
|
+
})
|
|
103
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
104
|
+
expect(xml).toBe('<link href="https://example.com"/>')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('Fragment support', () => {
|
|
109
|
+
it('should render fragment children without wrapper', async () => {
|
|
110
|
+
const node = createElement(
|
|
111
|
+
Fragment,
|
|
112
|
+
null,
|
|
113
|
+
createElement('item', null, 'A'),
|
|
114
|
+
createElement('item', null, 'B'),
|
|
115
|
+
)
|
|
116
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
117
|
+
expect(xml).toBe('<item>A</item><item>B</item>')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should handle nested fragments', async () => {
|
|
121
|
+
const node = createElement(
|
|
122
|
+
'root',
|
|
123
|
+
null,
|
|
124
|
+
createElement(
|
|
125
|
+
Fragment,
|
|
126
|
+
null,
|
|
127
|
+
createElement('a', null),
|
|
128
|
+
createElement('b', null),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
132
|
+
expect(xml).toBe('<root><a/><b/></root>')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('async components', () => {
|
|
137
|
+
it('should resolve async components', async () => {
|
|
138
|
+
const AsyncComponent = async () => {
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
140
|
+
return createElement('async', null, 'loaded')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const node = createElement(AsyncComponent, null)
|
|
144
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
145
|
+
expect(xml).toBe('<async>loaded</async>')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should resolve multiple async components in parallel', async () => {
|
|
149
|
+
const order: number[] = []
|
|
150
|
+
|
|
151
|
+
const AsyncA = async () => {
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
153
|
+
order.push(1)
|
|
154
|
+
return createElement('a', null)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const AsyncB = async () => {
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
159
|
+
order.push(2)
|
|
160
|
+
return createElement('b', null)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const node = createElement(
|
|
164
|
+
'root',
|
|
165
|
+
null,
|
|
166
|
+
createElement(AsyncA, null),
|
|
167
|
+
createElement(AsyncB, null),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
171
|
+
expect(xml).toBe('<root><a/><b/></root>')
|
|
172
|
+
// B should complete before A due to shorter timeout
|
|
173
|
+
expect(order).toEqual([2, 1])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should handle nested async components', async () => {
|
|
177
|
+
const Inner = async () => {
|
|
178
|
+
return createElement('inner', null, 'content')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const Outer = async () => {
|
|
182
|
+
return createElement('outer', null, createElement(Inner, null))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const node = createElement(Outer, null)
|
|
186
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
187
|
+
expect(xml).toBe('<outer><inner>content</inner></outer>')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should pass props to async components', async () => {
|
|
191
|
+
const AsyncGreeting = async ({ name }: { name: string }) => {
|
|
192
|
+
return createElement('greeting', null, `Hello, ${name}!`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const node = createElement(AsyncGreeting, { name: 'World' })
|
|
196
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
197
|
+
expect(xml).toBe('<greeting>Hello, World!</greeting>')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('CDATA support', () => {
|
|
202
|
+
it('should render CDATA sections', async () => {
|
|
203
|
+
const node = createElement(
|
|
204
|
+
'description',
|
|
205
|
+
null,
|
|
206
|
+
CData({ children: 'Some <html> content & more' }),
|
|
207
|
+
)
|
|
208
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
209
|
+
expect(xml).toBe(
|
|
210
|
+
'<description><![CDATA[Some <html> content & more]]></description>',
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should handle CDATA containing ]]>', async () => {
|
|
215
|
+
const node = createElement(
|
|
216
|
+
'data',
|
|
217
|
+
null,
|
|
218
|
+
CData({ children: 'Before ]]> After' }),
|
|
219
|
+
)
|
|
220
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
221
|
+
expect(xml).toBe('<data><![CDATA[Before ]]]]><![CDATA[> After]]></data>')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('should handle multiple ]]> in CDATA', async () => {
|
|
225
|
+
const node = createElement(
|
|
226
|
+
'data',
|
|
227
|
+
null,
|
|
228
|
+
CData({ children: 'A ]]> B ]]> C' }),
|
|
229
|
+
)
|
|
230
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
231
|
+
expect(xml).toBe(
|
|
232
|
+
'<data><![CDATA[A ]]]]><![CDATA[> B ]]]]><![CDATA[> C]]></data>',
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('DangerouslyInsertRawXml support', () => {
|
|
238
|
+
it('should insert raw XML without escaping', async () => {
|
|
239
|
+
const htmlContent = '<p>Hello <strong>World</strong></p>'
|
|
240
|
+
const node = createElement(
|
|
241
|
+
'content',
|
|
242
|
+
null,
|
|
243
|
+
DangerouslyInsertRawXml({ children: htmlContent }),
|
|
244
|
+
)
|
|
245
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
246
|
+
expect(xml).toBe('<content><p>Hello <strong>World</strong></p></content>')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should handle complex raw XML', async () => {
|
|
250
|
+
const rawXml = '<nested><element attr="value">text</element></nested>'
|
|
251
|
+
const node = createElement(
|
|
252
|
+
'wrapper',
|
|
253
|
+
null,
|
|
254
|
+
DangerouslyInsertRawXml({ children: rawXml }),
|
|
255
|
+
)
|
|
256
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
257
|
+
expect(xml).toBe(
|
|
258
|
+
'<wrapper><nested><element attr="value">text</element></nested></wrapper>',
|
|
259
|
+
)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('pretty printing', () => {
|
|
264
|
+
it('should format XML with indentation when pretty is true', async () => {
|
|
265
|
+
const node = createElement(
|
|
266
|
+
'root',
|
|
267
|
+
null,
|
|
268
|
+
createElement('child', null, createElement('grandchild', null)),
|
|
269
|
+
createElement('child2', null),
|
|
270
|
+
)
|
|
271
|
+
const xml = await renderToXml(node, { declaration: false, pretty: true })
|
|
272
|
+
expect(xml).toBe(`<root>
|
|
273
|
+
<child>
|
|
274
|
+
<grandchild/>
|
|
275
|
+
</child>
|
|
276
|
+
<child2/>
|
|
277
|
+
</root>
|
|
278
|
+
`)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should include declaration with newline when pretty', async () => {
|
|
282
|
+
const node = createElement('root', null)
|
|
283
|
+
const xml = await renderToXml(node, { pretty: true })
|
|
284
|
+
expect(xml).toBe(`<?xml version="1.0" encoding="UTF-8"?>
|
|
285
|
+
<root/>
|
|
286
|
+
`)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should keep text content inline', async () => {
|
|
290
|
+
const node = createElement(
|
|
291
|
+
'root',
|
|
292
|
+
null,
|
|
293
|
+
createElement('title', null, 'Hello'),
|
|
294
|
+
)
|
|
295
|
+
const xml = await renderToXml(node, { declaration: false, pretty: true })
|
|
296
|
+
expect(xml).toBe(`<root>
|
|
297
|
+
<title>Hello</title>
|
|
298
|
+
</root>
|
|
299
|
+
`)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe('sync components', () => {
|
|
304
|
+
it('should render sync function components', async () => {
|
|
305
|
+
const Greeting = ({ name }: { name: string }) =>
|
|
306
|
+
createElement('greeting', null, `Hello, ${name}!`)
|
|
307
|
+
|
|
308
|
+
const node = createElement(Greeting, { name: 'World' })
|
|
309
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
310
|
+
expect(xml).toBe('<greeting>Hello, World!</greeting>')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('should pass children to function components', async () => {
|
|
314
|
+
const Wrapper = ({ children }: { children: any[] }) =>
|
|
315
|
+
createElement('wrapper', null, ...children)
|
|
316
|
+
|
|
317
|
+
const node = createElement(Wrapper, null, createElement('child', null))
|
|
318
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
319
|
+
expect(xml).toBe('<wrapper><child/></wrapper>')
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('complex RSS-like structure', () => {
|
|
324
|
+
it('should render a complete RSS-like structure', async () => {
|
|
325
|
+
const node = createElement(
|
|
326
|
+
'rss',
|
|
327
|
+
{ version: '2.0' },
|
|
328
|
+
createElement(
|
|
329
|
+
'channel',
|
|
330
|
+
null,
|
|
331
|
+
createElement('title', null, 'My Blog'),
|
|
332
|
+
createElement('link', null, 'https://example.com'),
|
|
333
|
+
createElement(
|
|
334
|
+
'item',
|
|
335
|
+
null,
|
|
336
|
+
createElement('title', null, 'First Post'),
|
|
337
|
+
createElement('link', null, 'https://example.com/post/1'),
|
|
338
|
+
createElement(
|
|
339
|
+
'description',
|
|
340
|
+
null,
|
|
341
|
+
CData({ children: 'This has <html> in it' }),
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
createElement(
|
|
345
|
+
'item',
|
|
346
|
+
null,
|
|
347
|
+
createElement('title', null, 'Second Post'),
|
|
348
|
+
createElement('link', null, 'https://example.com/post/2'),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
const xml = await renderToXml(node)
|
|
354
|
+
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
355
|
+
expect(xml).toContain('<rss version="2.0">')
|
|
356
|
+
expect(xml).toContain('<title>My Blog</title>')
|
|
357
|
+
expect(xml).toContain('<![CDATA[This has <html> in it]]>')
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CDataNode, RawXmlNode } from '../types/xml-node.mjs'
|
|
2
|
+
import { CDataSymbol, RawXmlSymbol } from '../types/xml-node.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CDATA component - wraps content in <![CDATA[...]]>
|
|
6
|
+
* Use for text content that may contain characters like < > &
|
|
7
|
+
* that would otherwise need escaping.
|
|
8
|
+
*
|
|
9
|
+
* Note: If content contains "]]>", it will be split into multiple CDATA sections.
|
|
10
|
+
*/
|
|
11
|
+
export function CData({ children }: { children: string }): CDataNode {
|
|
12
|
+
return {
|
|
13
|
+
type: CDataSymbol,
|
|
14
|
+
content: String(children),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* DangerouslyInsertRawXml - inserts raw XML/HTML without any escaping or wrapping
|
|
20
|
+
*
|
|
21
|
+
* WARNING: This bypasses all XML escaping. Only use with trusted content!
|
|
22
|
+
* Use cases:
|
|
23
|
+
* - Pre-rendered XML fragments
|
|
24
|
+
* - HTML content in RSS/Atom feeds (in description/content:encoded)
|
|
25
|
+
* - Including XML from external sources that's already valid
|
|
26
|
+
*/
|
|
27
|
+
export function DangerouslyInsertRawXml({ children }: { children: string }): RawXmlNode {
|
|
28
|
+
return {
|
|
29
|
+
type: RawXmlSymbol,
|
|
30
|
+
content: String(children),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
2
|
+
import type { z } from 'zod/v4'
|
|
3
|
+
|
|
4
|
+
import type { AnyXmlNode, XmlNode } from '../types/xml-node.mjs'
|
|
5
|
+
|
|
6
|
+
export interface TagComponent<Props extends Record<string, unknown>> {
|
|
7
|
+
(props: Props & { children?: AnyXmlNode | AnyXmlNode[] }): XmlNode
|
|
8
|
+
tagName: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a type-safe XML tag component with optional Zod schema validation.
|
|
13
|
+
*
|
|
14
|
+
* @param name - The tag name (supports namespace prefixes like 'atom:link')
|
|
15
|
+
* @param propsSchema - Optional Zod schema for validating props
|
|
16
|
+
* @returns A component function that can be used in JSX
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* // Simple tag
|
|
21
|
+
* const item = defineTag('item')
|
|
22
|
+
* <item>Content</item>
|
|
23
|
+
*
|
|
24
|
+
* // Namespaced tag with Zod validation
|
|
25
|
+
* const atomLink = defineTag('atom:link', z.object({
|
|
26
|
+
* href: z.string().url(),
|
|
27
|
+
* rel: z.enum(['self', 'alternate']),
|
|
28
|
+
* type: z.string().optional(),
|
|
29
|
+
* }))
|
|
30
|
+
* <atomLink href="https://example.com/feed" rel="self" />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function defineTag<T extends ZodRawShape>(
|
|
34
|
+
name: string,
|
|
35
|
+
propsSchema?: ZodObject<T>,
|
|
36
|
+
): TagComponent<T extends ZodRawShape ? z.infer<ZodObject<T>> : Record<string, never>> {
|
|
37
|
+
const component = (props: any): XmlNode => {
|
|
38
|
+
const { children, ...rest } = props ?? {}
|
|
39
|
+
|
|
40
|
+
// Validate props if schema provided
|
|
41
|
+
if (propsSchema) {
|
|
42
|
+
propsSchema.parse(rest)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
type: name,
|
|
47
|
+
props: rest,
|
|
48
|
+
children: Array.isArray(children) ? children : children ? [children] : [],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
component.tagName = name
|
|
53
|
+
return component as any
|
|
54
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { z } from 'zod/v4'
|
|
3
|
+
|
|
4
|
+
import { renderToXml } from '../runtime/render-to-xml.mjs'
|
|
5
|
+
import { defineTag } from './define-tag.mjs'
|
|
6
|
+
|
|
7
|
+
describe('defineTag', () => {
|
|
8
|
+
describe('basic tags', () => {
|
|
9
|
+
it('should create a simple tag without props', () => {
|
|
10
|
+
const item = defineTag('item')
|
|
11
|
+
|
|
12
|
+
const node = item({})
|
|
13
|
+
expect(node).toEqual({
|
|
14
|
+
type: 'item',
|
|
15
|
+
props: {},
|
|
16
|
+
children: [],
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should handle tag name with namespace prefix', () => {
|
|
21
|
+
const atomLink = defineTag('atom:link')
|
|
22
|
+
|
|
23
|
+
const node = atomLink({})
|
|
24
|
+
expect(node.type).toBe('atom:link')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should expose tagName property', () => {
|
|
28
|
+
const myTag = defineTag('my-custom-tag')
|
|
29
|
+
expect(myTag.tagName).toBe('my-custom-tag')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('props handling', () => {
|
|
34
|
+
it('should pass props through to the node', () => {
|
|
35
|
+
const link = defineTag('link')
|
|
36
|
+
|
|
37
|
+
const node = link({ href: 'https://example.com', rel: 'self' })
|
|
38
|
+
expect(node.props).toEqual({
|
|
39
|
+
href: 'https://example.com',
|
|
40
|
+
rel: 'self',
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should handle undefined props gracefully', () => {
|
|
45
|
+
const item = defineTag('item')
|
|
46
|
+
|
|
47
|
+
const node = item(undefined as any)
|
|
48
|
+
expect(node).toEqual({
|
|
49
|
+
type: 'item',
|
|
50
|
+
props: {},
|
|
51
|
+
children: [],
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should handle null props gracefully', () => {
|
|
56
|
+
const item = defineTag('item')
|
|
57
|
+
|
|
58
|
+
const node = item(null as any)
|
|
59
|
+
expect(node).toEqual({
|
|
60
|
+
type: 'item',
|
|
61
|
+
props: {},
|
|
62
|
+
children: [],
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('children handling', () => {
|
|
68
|
+
it('should handle single child', () => {
|
|
69
|
+
const container = defineTag('container')
|
|
70
|
+
|
|
71
|
+
const node = container({ children: 'Hello' })
|
|
72
|
+
expect(node.children).toEqual(['Hello'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should handle array of children', () => {
|
|
76
|
+
const container = defineTag('container')
|
|
77
|
+
const child1 = { type: 'a', props: {}, children: [] }
|
|
78
|
+
const child2 = { type: 'b', props: {}, children: [] }
|
|
79
|
+
|
|
80
|
+
const node = container({ children: [child1, child2] })
|
|
81
|
+
expect(node.children).toEqual([child1, child2])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should handle no children', () => {
|
|
85
|
+
const selfClosing = defineTag('br')
|
|
86
|
+
|
|
87
|
+
const node = selfClosing({})
|
|
88
|
+
expect(node.children).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('Zod schema validation', () => {
|
|
93
|
+
it('should validate props against schema', () => {
|
|
94
|
+
const atomLink = defineTag(
|
|
95
|
+
'atom:link',
|
|
96
|
+
z.object({
|
|
97
|
+
href: z.string().url(),
|
|
98
|
+
rel: z.enum(['self', 'alternate']),
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const node = atomLink({
|
|
103
|
+
href: 'https://example.com/feed',
|
|
104
|
+
rel: 'self',
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(node.type).toBe('atom:link')
|
|
108
|
+
expect(node.props).toEqual({
|
|
109
|
+
href: 'https://example.com/feed',
|
|
110
|
+
rel: 'self',
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should throw on invalid props', () => {
|
|
115
|
+
const atomLink = defineTag(
|
|
116
|
+
'atom:link',
|
|
117
|
+
z.object({
|
|
118
|
+
href: z.string().url(),
|
|
119
|
+
rel: z.enum(['self', 'alternate']),
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
expect(() =>
|
|
124
|
+
atomLink({
|
|
125
|
+
href: 'not-a-valid-url',
|
|
126
|
+
rel: 'self',
|
|
127
|
+
}),
|
|
128
|
+
).toThrow()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should throw on missing required props', () => {
|
|
132
|
+
const atomLink = defineTag(
|
|
133
|
+
'atom:link',
|
|
134
|
+
z.object({
|
|
135
|
+
href: z.string().url(),
|
|
136
|
+
rel: z.enum(['self', 'alternate']),
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
expect(() =>
|
|
141
|
+
atomLink({
|
|
142
|
+
href: 'https://example.com',
|
|
143
|
+
// missing rel
|
|
144
|
+
} as any),
|
|
145
|
+
).toThrow()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should allow optional props in schema', () => {
|
|
149
|
+
const atomLink = defineTag(
|
|
150
|
+
'atom:link',
|
|
151
|
+
z.object({
|
|
152
|
+
href: z.string().url(),
|
|
153
|
+
rel: z.enum(['self', 'alternate']),
|
|
154
|
+
type: z.string().optional(),
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const node = atomLink({
|
|
159
|
+
href: 'https://example.com/feed',
|
|
160
|
+
rel: 'self',
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
expect(node.props).toEqual({
|
|
164
|
+
href: 'https://example.com/feed',
|
|
165
|
+
rel: 'self',
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should include optional props when provided', () => {
|
|
170
|
+
const atomLink = defineTag(
|
|
171
|
+
'atom:link',
|
|
172
|
+
z.object({
|
|
173
|
+
href: z.string().url(),
|
|
174
|
+
rel: z.enum(['self', 'alternate']),
|
|
175
|
+
type: z.string().optional(),
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const node = atomLink({
|
|
180
|
+
href: 'https://example.com/feed',
|
|
181
|
+
rel: 'self',
|
|
182
|
+
type: 'application/rss+xml',
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
expect(node.props).toEqual({
|
|
186
|
+
href: 'https://example.com/feed',
|
|
187
|
+
rel: 'self',
|
|
188
|
+
type: 'application/rss+xml',
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('integration with renderToXml', () => {
|
|
194
|
+
it('should render a defined tag', async () => {
|
|
195
|
+
const item = defineTag('item')
|
|
196
|
+
const title = defineTag('title')
|
|
197
|
+
|
|
198
|
+
const node = item({ children: [title({ children: 'Hello World' })] })
|
|
199
|
+
|
|
200
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
201
|
+
expect(xml).toBe('<item><title>Hello World</title></item>')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should render a namespaced tag with attributes', async () => {
|
|
205
|
+
const atomLink = defineTag(
|
|
206
|
+
'atom:link',
|
|
207
|
+
z.object({
|
|
208
|
+
href: z.string().url(),
|
|
209
|
+
rel: z.enum(['self', 'alternate']),
|
|
210
|
+
type: z.string().optional(),
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const node = atomLink({
|
|
215
|
+
href: 'https://example.com/feed',
|
|
216
|
+
rel: 'self',
|
|
217
|
+
type: 'application/rss+xml',
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
221
|
+
expect(xml).toBe('<atom:link href="https://example.com/feed" rel="self" type="application/rss+xml"/>')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('should compose multiple defined tags', async () => {
|
|
225
|
+
const rss = defineTag(
|
|
226
|
+
'rss',
|
|
227
|
+
z.object({
|
|
228
|
+
version: z.literal('2.0'),
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
const channel = defineTag('channel')
|
|
232
|
+
const title = defineTag('title')
|
|
233
|
+
const link = defineTag('link')
|
|
234
|
+
|
|
235
|
+
const node = rss({
|
|
236
|
+
version: '2.0',
|
|
237
|
+
children: [
|
|
238
|
+
channel({
|
|
239
|
+
children: [title({ children: 'My Feed' }), link({ children: 'https://example.com' })],
|
|
240
|
+
}),
|
|
241
|
+
],
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const xml = await renderToXml(node, { declaration: false })
|
|
245
|
+
expect(xml).toBe(
|
|
246
|
+
'<rss version="2.0"><channel><title>My Feed</title><link>https://example.com</link></channel></rss>',
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
})
|