Skip to content

Commit d3b6832

Browse files
committed
refactor: improve attribute handling and child processing in h function; enhance serialization tests for new elements
1 parent e2baa20 commit d3b6832

File tree

8 files changed

+119
-100
lines changed

8 files changed

+119
-100
lines changed

src/h.ts

Lines changed: 13 additions & 26 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
}
@@ -90,7 +77,7 @@ function _h(
9077

9178
export function hArgumentParser(
9279
tag: string | ((props: any) => VDocumentFragment | VElement),
93-
attrs?: Record<string, unknown> | null,
80+
attrs?: Record<string, unknown> | unknown[] | null,
9481
...childrenInput: unknown[]
9582
): { tag: string | ((props: any) => VDocumentFragment | VElement), attrs: Record<string, unknown>, children: unknown[] } {
9683
let children: unknown[] = childrenInput

src/html.spec.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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

56
describe('html', () => {
67
it('should generate a string', () => {
@@ -83,4 +84,19 @@ describe('html', () => {
8384
expect(h('div', null, 123)).toBe('<div>123</div>')
8485
expect(h('div', null, { toString: () => '<foo>' })).toBe('<div>&lt;foo&gt;</div>')
8586
})
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+
})
86102
})

src/html.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
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'
9-
import { VDocumentFragment, VElement } from './vdom'
1010

1111
export const SELF_CLOSING_TAGS = [
1212
'area',
@@ -46,7 +46,7 @@ export function markup(
4646
|| (Array.isArray(children)
4747
&& (children.length === 0
4848
|| (children.length === 1 && children[0] === '')))
49-
|| children == null
49+
|| children == null
5050
)
5151

5252
const parts: string[] = []
@@ -75,11 +75,10 @@ export function markup(
7575
parts.push(` ${name}`)
7676
}
7777
else if (name === 'style' && typeof v === 'object') {
78-
const styleStr = 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
78+
const styleStr = Object.entries(v)
79+
.filter(([, val]) => val != null)
80+
.map(([k, val]) => {
81+
const vv = typeof val === 'number' ? `${val}px` : val
8382
return `${k.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}:${vv}`
8483
})
8584
.join(';')

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
})

src/serialize-markdown.ts

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,57 +7,60 @@ interface SerializeContext {
77
mode?: 'ol' | 'ul'
88
}
99

10+
// Build rules map only once for performance
11+
const rules: Record<string, (node: VElement, handleChildren: (ctx?: Partial<SerializeContext>) => string, ctx?: SerializeContext) => string> = {
12+
b: (node, h) => `**${h()}**`,
13+
strong: (node, h) => `**${h()}**`,
14+
i: (node, h) => `*${h()}*`,
15+
em: (node, h) => `*${h()}*`,
16+
u: (node, h) => `<u>${h()}</u>`,
17+
mark: (node, h) => `==${h()}==`,
18+
tt: (node, h) => `==${h()}==`,
19+
code: (node, h) => `==${h()}==`,
20+
strike: (node, h) => `~~${h()}~~`,
21+
sub: (node, h) => `~${h()}~`,
22+
super: (node, h) => `^${h()}^`,
23+
sup: (node, h) => `^${h()}^`,
24+
li: (node, h) => `- ${h()}\n`,
25+
br: (node, h) => `\n`,
26+
ol: (node, h, ctx) => `\n\n${h({ level: (ctx?.level ?? 0) + 1 })}\n\n`,
27+
ul: (node, h, ctx) => `\n\n${h({ level: (ctx?.level ?? 0) + 1 })}\n\n`,
28+
blockquote: (node, h) => `\n\n> ${h()}\n\n`,
29+
pre: (node, h) => `\n\n\`\`\`\n${h()}\n\`\`\`\n\n`,
30+
p: (node, h) => `\n\n${h()}\n\n`,
31+
div: (node, h) => `\n\n${h()}\n\n`,
32+
h1: (node, h) => `\n\n# ${h()}\n\n`,
33+
h2: (node, h) => `\n\n## ${h()}\n\n`,
34+
h3: (node, h) => `\n\n### ${h()}\n\n`,
35+
h4: (node, h) => `\n\n#### ${h()}\n\n`,
36+
h5: (node, h) => `\n\n##### ${h()}\n\n`,
37+
h6: (node, h) => `\n\n###### ${h()}\n\n`,
38+
hr: () => `\n\n---\n\n`,
39+
a: (node, h) => `[${h()}](${node.getAttribute('href') ?? '#'})`,
40+
img: node => `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`,
41+
del: (node, h) => `~~${h()}~~`,
42+
ins: (node, h) => `++${h()}++`,
43+
span: (node, h) => h(),
44+
table: (node, h) => `\n\n${h()}\n\n`,
45+
tr: (node, h) => `|${h()}|\n`,
46+
th: (node, h) => ` ${h()} |`,
47+
td: (node, h) => ` ${h()} |`,
48+
caption: (node, h) => `\n${h()}\n`,
49+
}
50+
1051
function serialize(node: VNode | VElement, context: SerializeContext = {
1152
level: 0,
1253
count: 0,
1354
}): string {
1455
if (node.nodeType === VNode.DOCUMENT_FRAGMENT_NODE) {
15-
return node.children.map(c => serialize(c, { ...context })).join('')
56+
return (node.children || []).map(c => serialize(c, { ...context })).join('')
1657
}
17-
1858
else if (isVElement(node)) {
1959
const tag: string = node.tagName.toLowerCase()
20-
21-
const handleChildren = (ctx?: Partial<SerializeContext>): string => node.children.map(c => serialize(c, { ...context, ...ctx })).join('')
22-
23-
const rules: Record<string, () => string> = {
24-
b: () => `**${handleChildren()}**`,
25-
strong: () => `**${handleChildren()}**`,
26-
i: () => `*${handleChildren()}*`,
27-
em: () => `*${handleChildren()}*`,
28-
u: () => `<u>${handleChildren()}</u>`,
29-
mark: () => `==${handleChildren()}==`,
30-
tt: () => `==${handleChildren()}==`,
31-
code: () => `==${handleChildren()}==`,
32-
strike: () => `~~${handleChildren()}~~`,
33-
sub: () => `~${handleChildren()}~`,
34-
super: () => `^${handleChildren()}^`,
35-
sup: () => `^${handleChildren()}^`,
36-
li: () => `- ${handleChildren()}\n`, // todo numbered
37-
br: () => `${handleChildren()}\n`,
38-
ol: () => `\n\n${handleChildren({ level: context.level + 1 })}\n\n`, // todo indent
39-
ul: () => `\n\n${handleChildren({ level: context.level + 1 })}\n\n`, // todo indent
40-
blockquote: () => `\n\n> ${handleChildren()}\n\n`, // todo continue '>'
41-
pre: () => `\n\n\`\`\`\n${handleChildren()}\n\`\`\`\n\n`,
42-
p: () => `\n\n${handleChildren()}\n\n`,
43-
div: () => `\n\n${handleChildren()}\n\n`,
44-
h1: () => `\n\n# ${handleChildren()}\n\n`,
45-
h2: () => `\n\n## ${handleChildren()}\n\n`,
46-
h3: () => `\n\n### ${handleChildren()}\n\n`,
47-
h4: () => `\n\n#### ${handleChildren()}\n\n`,
48-
h5: () => `\n\n##### ${handleChildren()}\n\n`,
49-
h6: () => `\n\n###### ${handleChildren()}\n\n`,
50-
hr: () => `\n\n---\n\n`,
51-
a: () => `[${handleChildren()}](${node.getAttribute('href') ?? '#'})`,
52-
img: () => `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`,
53-
54-
// todo audio, video and other HTML stuff
55-
}
56-
60+
const handleChildren = (ctx?: Partial<SerializeContext>): string => (node.children || []).map(c => serialize(c, { ...context, ...ctx })).join('')
5761
const fn = rules[tag]
58-
5962
if (fn)
60-
return fn()
63+
return fn(node as VElement, handleChildren, context)
6164
else
6265
return handleChildren()
6366
}

src/serialize-safehtml.ts

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { VElement } from './vdom'
12
import { escapeHTML } from './encoding'
23
import { isVElement, VNode } from './vdom'
34
import { parseHTML } from './vdomparser'
@@ -10,37 +11,34 @@ interface SerializeContext {
1011
mode?: 'ol' | 'ul'
1112
}
1213

14+
// Build rules map only once for performance
15+
const blockTags = SELECTOR_BLOCK_ELEMENTS.split(',')
16+
const baseRules: Record<string, (node: VElement, handleChildren: (ctx?: Partial<SerializeContext>) => string) => string> = {
17+
a: (node, handleChildren) => `<a href="${escapeHTML(node.getAttribute('href') ?? '')}" rel="noopener noreferrer" target="_blank">${handleChildren()}</a>`,
18+
img: node => `<img src="${escapeHTML(node.getAttribute('src') ?? '')}" alt="${escapeHTML(node.getAttribute('alt') ?? '')}">`,
19+
br: () => `<br>`,
20+
title: () => '',
21+
script: () => '',
22+
style: () => '',
23+
head: () => '',
24+
}
25+
blockTags.forEach((tag) => {
26+
baseRules[tag] = (node, handleChildren) => `<${tag}>${handleChildren().trim()}</${tag}>`
27+
})
28+
1329
function serialize(node: VNode, context: SerializeContext = {
1430
level: 0,
1531
count: 0,
1632
}): string {
1733
if (node.nodeType === VNode.DOCUMENT_FRAGMENT_NODE) {
18-
return node.children.map(c => serialize(c, { ...context })).join('')
34+
return (node.children || []).map(c => serialize(c, { ...context })).join('')
1935
}
20-
2136
else if (isVElement(node)) {
2237
const tag: string = node.tagName?.toLowerCase()
23-
const handleChildren = (ctx?: Partial<SerializeContext>): string => node.children.map(c => serialize(c, { ...context, ...ctx })).join('')
24-
25-
const rules: Record<string, () => string> = {
26-
a: () => `<a href="${escapeHTML(node.getAttribute('href') ?? '')}" rel="noopener noreferrer" target="_blank">${handleChildren()}</a>`,
27-
img: () => `<img src="${escapeHTML(node.getAttribute('src') ?? '')}" alt="${escapeHTML(node.getAttribute('alt') ?? '')}">`,
28-
br: () => `<br>`,
29-
title: () => '',
30-
script: () => '',
31-
style: () => '',
32-
head: () => '',
33-
}
34-
35-
SELECTOR_BLOCK_ELEMENTS.split(',').forEach((tag) => {
36-
rules[tag] = () => `<${tag}>${handleChildren().trim()}</${tag}>`
37-
})
38-
39-
const fn = rules[tag]
40-
38+
const handleChildren = (ctx?: Partial<SerializeContext>): string => (node.children || []).map(c => serialize(c, { ...context, ...ctx })).join('')
39+
const fn = baseRules[tag]
4140
if (fn)
42-
return fn()
43-
41+
return fn(node, handleChildren)
4442
return handleChildren()
4543
}
4644
return escapeHTML(node.textContent ?? '')

src/vdom.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,7 @@ describe('vdom', () => {
651651
expect(el.childElementCount).toBe(3)
652652
el.insertAdjacentHTML('afterend', '<footer>afterend</footer>')
653653
expect(parent.render()).not.toBe(parentHTML)
654-
expect(parent.render()).toMatchInlineSnapshot(`"<div><section>beforebegin</section><div><i>afterbegin</i>text<span>beforeend</span><b>beforeend</b></div><footer>afterend</footer></div>"`)
654+
expect(parent.render()).toMatchInlineSnapshot(`"<div><section>beforebegin</section><div><i>afterbegin</i>text<span>beforeend</span><b>beforeend</b></div><footer>afterend</footer></div>"`)
655655
expect(parent.childElementCount).toBe(3)
656656
})
657657

src/vdomparser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ VElement.prototype.insertAdjacentHTML = function (
8484
try {
8585
const frag = parseHTML(text)
8686
nodes = frag._childNodes.filter((n: any) => n instanceof VNode)
87-
} catch (e) {
87+
}
88+
catch (e) {
8889
// Only fallback if text is not valid HTML
8990
if (/^\s*<\/?[a-zA-Z]/.test(text)) {
9091
throw new Error('HTML parsing failed in insertAdjacentHTML')
@@ -120,4 +121,3 @@ VElement.prototype.insertAdjacentHTML = function (
120121
break
121122
}
122123
}
123-

0 commit comments

Comments
 (0)