Skip to content

Commit 93fb60d

Browse files
committed
Merge branch 'release/v0.17.0'
2 parents 22b8838 + ec4c062 commit 93fb60d

14 files changed

+1058
-350
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zeed-dom",
33
"type": "module",
4-
"version": "0.16.1",
4+
"version": "0.17.0",
55
"description": "🌱 Lightweight offline DOM",
66
"author": {
77
"name": "Dirk Holtwick",

src/h.ts

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -41,44 +41,31 @@ function _h(
4141
}
4242
if (attrs && isElement) {
4343
const element = el as VElement
44-
for (let [key, value] of Object.entries(attrs)) {
45-
key = key.toString()
44+
for (const [key, value] of Object.entries(attrs)) {
4645
const compareKey = key.toLowerCase()
4746
if (compareKey === 'classname') {
4847
element.className = value
4948
}
5049
else if (compareKey === 'on') {
51-
Object.entries(value).forEach(([name, value]) => {
52-
element.setAttribute(`on${name}`, String(value))
53-
})
54-
// else if (key.indexOf('on') === 0) {
55-
// if (el.addEventListener) {
56-
// el.addEventListener(key.substring(2), value)
57-
// continue
58-
// }
50+
for (const [name, v] of Object.entries(value)) {
51+
element.setAttribute(`on${name}`, String(v))
52+
}
5953
}
6054
else if (value !== false && value != null) {
61-
if (value === true)
62-
element.setAttribute(key, key)
63-
else
64-
element.setAttribute(key, value.toString())
55+
element.setAttribute(key, value === true ? key : value.toString())
6556
}
6657
}
6758
}
6859
if (children) {
6960
for (const childOuter of children) {
70-
const cc = Array.isArray(childOuter) ? [...childOuter] : [childOuter]
61+
const cc = Array.isArray(childOuter) ? childOuter : [childOuter]
7162
for (const child of cc) {
72-
if (child) {
73-
if (child !== false && child != null) {
74-
if (typeof child !== 'object') {
75-
el.appendChild(
76-
context.document.createTextNode(child.toString()),
77-
)
78-
}
79-
else {
80-
el.appendChild(child)
81-
}
63+
if (child !== false && child != null) {
64+
if (typeof child !== 'object') {
65+
el.appendChild(context.document.createTextNode(child.toString()))
66+
}
67+
else {
68+
el.appendChild(child)
8269
}
8370
}
8471
}
@@ -88,30 +75,42 @@ function _h(
8875
}
8976
}
9077

91-
export function hArgumentParser(tag: any, attrs: any, ...children: any[]) {
92-
if (typeof tag === 'object') {
78+
export function hArgumentParser(
79+
tag: string | ((props: any) => VDocumentFragment | VElement),
80+
attrs?: Record<string, unknown> | unknown[] | null,
81+
...childrenInput: unknown[]
82+
): { tag: string | ((props: any) => VDocumentFragment | VElement), attrs: Record<string, unknown>, children: unknown[] } {
83+
let children: unknown[] = childrenInput
84+
85+
if (typeof tag === 'object' && tag !== null) {
86+
// If tag is an object, treat as fragment-like
87+
children = (tag as any).children
88+
attrs = (tag as any).attrs
9389
tag = 'fragment'
94-
children = tag.children
95-
attrs = tag.attrs
9690
}
91+
9792
if (Array.isArray(attrs)) {
9893
children = [attrs]
9994
attrs = {}
10095
}
10196
else if (attrs) {
102-
if (attrs.attrs) {
103-
attrs = { ...attrs.attrs, ...attrs }
104-
delete attrs.attrs
97+
if ((attrs as Record<string, unknown>).attrs) {
98+
const attrsObj = (attrs as Record<string, unknown>).attrs
99+
attrs = { ...(typeof attrsObj === 'object' && attrsObj !== null ? attrsObj : {}), ...attrs }
100+
delete (attrs as Record<string, unknown>).attrs
105101
}
106102
}
107103
else {
108104
attrs = {}
109105
}
106+
110107
return {
111108
tag,
112-
attrs,
109+
attrs: attrs ?? {},
113110
children:
114-
typeof children[0] === 'string' ? children : children.flat(Number.POSITIVE_INFINITY),
111+
typeof children[0] === 'string'
112+
? children
113+
: children.flat(Number.POSITIVE_INFINITY),
115114
}
116115
}
117116

src/html.spec.tsx

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/** @jsx h */
22

3-
import { CDATA, html as h } from './html'
3+
import { hArgumentParser } from './h'
4+
import { CDATA, html as h, markup } from './html'
45

5-
describe('hTML', () => {
6+
describe('html', () => {
67
it('should generate a string', () => {
78
const s = h('a', { href: 'example.com' }, 'Welcome & Hello &amp; Ciao')
89
expect(s).toEqual(
@@ -23,7 +24,7 @@ describe('hTML', () => {
2324
const s = (
2425
<a href="example.com" x="x" hidden={false} {...spread}>
2526
<hr myCaseSensitiveAttribute="1" />
26-
{null && 'This is invisible'}
27+
{false && 'This is invisible'}
2728
<b>Welcome</b>
2829
</a>
2930
)
@@ -42,4 +43,60 @@ describe('hTML', () => {
4243
`"<div><![CDATA[<b>Do not escape! & </b>]]></div>"`,
4344
)
4445
})
46+
47+
it('should handle style attribute with various cases', () => {
48+
// Style with null/undefined values
49+
expect(
50+
h('div', { style: { color: 'red', margin: null, padding: undefined } }),
51+
).toBe('<div style="color:red"></div>')
52+
// Style with number (should add px)
53+
expect(h('div', { style: { width: 10, height: 0 } })).toBe(
54+
'<div style="width:10px;height:0px"></div>',
55+
)
56+
// Style with camelCase (should convert to kebab-case)
57+
expect(h('div', { style: { backgroundColor: 'blue' } })).toBe(
58+
'<div style="background-color:blue"></div>',
59+
)
60+
// Style with empty object (should not add style)
61+
expect(h('div', { style: {} })).toBe('<div></div>')
62+
})
63+
64+
it('should handle children as arrays, nested arrays, and skip null/false', () => {
65+
expect(h('div', null, ['a', null, false, h('b', null, 1)])).toBe(
66+
'<div>a<b>1</b></div>',
67+
)
68+
})
69+
70+
it('should not escape children that are strings starting/ending with < >', () => {
71+
expect(h('div', null, '<b>raw</b>')).toBe('<div><b>raw</b></div>')
72+
})
73+
74+
it('should not escape children for script/style tags', () => {
75+
expect(h('script', null, 'if (a < b) { alert(1) }')).toBe(
76+
'<script>if (a < b) { alert(1) }</script>',
77+
)
78+
expect(h('style', null, 'body { color: red; }')).toBe(
79+
'<style>body { color: red; }</style>',
80+
)
81+
})
82+
83+
it('should stringify and escape children that are objects or numbers', () => {
84+
expect(h('div', null, 123)).toBe('<div>123</div>')
85+
expect(h('div', null, { toString: () => '<foo>' })).toBe('<div>&lt;foo&gt;</div>')
86+
})
87+
88+
it('should handle xmlMode and self-closing tags', () => {
89+
// xmlMode true, no children
90+
expect(hArgumentParser('img', { src: 'x.png' })).toBeDefined()
91+
expect(markup(true, 'img', { src: 'x.png' }, undefined)).toBe('<img src="x.png" />')
92+
93+
// xmlMode true, with children
94+
expect(markup(true, 'div', {}, ['foo'])).toBe('<div>foo</div>')
95+
96+
// xmlMode false, self-closing tag
97+
expect(markup(false, 'img', { src: 'x.png' }, undefined)).toBe('<img src="x.png">')
98+
99+
// cdata
100+
expect(markup(false, 'cdata', {}, 'foo')).toBe('<![CDATA[foo]]>')
101+
})
45102
})

src/html.ts

Lines changed: 57 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// 2. Attribute name '__' gets transformed to ':' for namespace emulation
44
// 3. Emulate CDATA by <cdata> element
55

6+
import type { VDocumentFragment, VElement } from './vdom'
67
import { escapeHTML } from './encoding'
78
import { hArgumentParser } from './h'
89
import { hasOwn } from './utils'
@@ -53,46 +54,43 @@ export function markup(
5354

5455
// React fragment <>...</> and ours: <noop>...</noop>
5556
if (tag !== 'noop' && tag !== '') {
56-
if (tag !== 'cdata')
57+
if (tag !== 'cdata') {
5758
parts.push(`<${tag}`)
58-
else
59+
}
60+
else {
5961
parts.push('<![CDATA[')
62+
}
6063

6164
// Add attributes
6265
for (let name in attrs) {
63-
if (name && hasOwn(attrs, name)) {
64-
const v = attrs[name]
65-
if (name === 'html')
66-
continue
67-
68-
if (name.toLowerCase() === 'classname')
69-
name = 'class'
70-
71-
name = name.replace(/__/g, ':')
72-
if (v === true) {
73-
// s.push( ` ${name}="${name}"`)
74-
parts.push(` ${name}`)
75-
}
76-
else if (name === 'style' && typeof v === 'object') {
77-
parts.push(
78-
` ${name}="${Object.keys(v)
79-
.filter(k => v[k] != null)
80-
.map((k) => {
81-
let vv = v[k]
82-
vv = typeof vv === 'number' ? `${vv}px` : vv
83-
return `${k
84-
.replace(/([a-z])([A-Z])/g, '$1-$2')
85-
.toLowerCase()}:${vv}`
86-
})
87-
.join(';')}"`,
88-
)
89-
}
90-
else if (v !== false && v != null) {
91-
parts.push(` ${name}="${escapeHTML(v.toString())}"`)
92-
}
66+
if (!name || !hasOwn(attrs, name))
67+
continue
68+
const v = attrs[name]
69+
if (name === 'html')
70+
continue
71+
if (name.toLowerCase() === 'classname')
72+
name = 'class'
73+
name = name.replace(/__/g, ':')
74+
if (v === true) {
75+
parts.push(` ${name}`)
76+
}
77+
else if (name === 'style' && typeof v === 'object') {
78+
const styleStr = Object.entries(v)
79+
.filter(([, val]) => val != null)
80+
.map(([k, val]) => {
81+
const vv = typeof val === 'number' ? `${val}px` : val
82+
return `${k.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}:${vv}`
83+
})
84+
.join(';')
85+
if (styleStr)
86+
parts.push(` ${name}="${styleStr}"`)
87+
}
88+
else if (v !== false && v != null) {
89+
parts.push(` ${name}="${escapeHTML(v.toString())}"`)
9390
}
9491
}
9592

93+
const isSelfClosing = !xmlMode && SELF_CLOSING_TAGS.includes(tag)
9694
if (tag !== 'cdata') {
9795
if (xmlMode && !hasChildren) {
9896
parts.push(' />')
@@ -102,8 +100,7 @@ export function markup(
102100
parts.push('>')
103101
}
104102
}
105-
106-
if (!xmlMode && SELF_CLOSING_TAGS.includes(tag))
103+
if (isSelfClosing)
107104
return parts.join('')
108105
}
109106

@@ -113,25 +110,29 @@ export function markup(
113110
parts.push(children)
114111
}
115112
else if (children && children.length > 0) {
116-
for (let child of children) {
117-
if (child != null && child !== false) {
118-
if (!Array.isArray(child))
119-
child = [child]
120-
113+
for (const child of children) {
114+
if (child == null || child === false)
115+
continue
116+
if (Array.isArray(child)) {
121117
for (const c of child) {
122-
// todo: this fails if textContent starts with `<` and ends with `>`
123-
if (
124-
(c.startsWith('<') && c.endsWith('>'))
125-
|| tag === 'script'
126-
|| tag === 'style'
127-
) {
118+
if (c == null || c === false)
119+
continue
120+
if ((typeof c === 'string' && c.startsWith('<') && c.endsWith('>')) || tag === 'script' || tag === 'style') {
128121
parts.push(c)
129122
}
130123
else {
131124
parts.push(escapeHTML(c.toString()))
132125
}
133126
}
134127
}
128+
else {
129+
if ((typeof child === 'string' && child.startsWith('<') && child.endsWith('>')) || tag === 'script' || tag === 'style') {
130+
parts.push(child)
131+
}
132+
else {
133+
parts.push(escapeHTML(child.toString()))
134+
}
135+
}
135136
}
136137
}
137138
}
@@ -140,17 +141,23 @@ export function markup(
140141
parts.push(attrs.html)
141142

142143
if (tag !== 'noop' && tag !== '') {
143-
if (tag !== 'cdata')
144+
if (tag !== 'cdata') {
144145
parts.push(`</${tag}>`)
145-
else
146+
}
147+
else {
146148
parts.push(']]>')
149+
}
147150
}
148151
return parts.join('')
149152
}
150153

151-
export function html(itag: string, iattrs?: object, ...ichildren: any[]) {
152-
const { tag, attrs, children } = hArgumentParser(itag, iattrs, ichildren)
153-
return markup(false, tag, attrs, children)
154+
export function html(
155+
tag: string | ((props: any) => VDocumentFragment | VElement),
156+
attrs?: Record<string, unknown> | null,
157+
...children: unknown[]
158+
): string {
159+
const parsed = hArgumentParser(tag, attrs, ...children)
160+
return markup(false, parsed.tag as string, parsed.attrs, parsed.children)
154161
}
155162

156163
export const htmlVDOM = markup.bind(null, false)

src/manipulate.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('manipulate', () => {
2828
<title>Some - More!</title>
2929
</head>
3030
<body>
31-
<p class="img-wrapper" title="hello"><img src="/assets/ocr@2x-97ede361.png" alt="" width="621" height="422"></p>
31+
<p class="img-wrapper"><img src="/assets/ocr@2x-97ede361.png" alt="" width="621" height="422" title="hello"></p>
3232
This is &nbsp; spaaace!
3333
</body>
3434
</html>

src/serialize-markdown.spec.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,20 @@ describe('serialize', () => {
8686
"
8787
`)
8888
})
89+
90+
it('should serialize del, ins, span, and table elements', () => {
91+
// del
92+
expect(serializeMarkdown(h('del', null, 'strike'))).toBe('~~strike~~\n')
93+
// ins
94+
expect(serializeMarkdown(h('ins', null, 'inserted'))).toBe('++inserted++\n')
95+
// span (should just pass through)
96+
expect(serializeMarkdown(h('span', null, 'inline'))).toBe('inline\n')
97+
// table
98+
expect(serializeMarkdown(h('table', null,
99+
h('caption', null, 'Title'),
100+
h('tr', null, h('th', null, 'A'), h('th', null, 'B')),
101+
h('tr', null, h('td', null, '1'), h('td', null, '2')),
102+
))).toContain('| A | B |')
103+
expect(serializeMarkdown(h('caption', null, 'Cap'))).toContain('Cap')
104+
})
89105
})

0 commit comments

Comments
 (0)