Skip to content

Commit 665e457

Browse files
feat(core): add svg template tag
1 parent 2f0b224 commit 665e457

7 files changed

Lines changed: 166 additions & 15 deletions

File tree

docs/public/badges/core-size.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schemaVersion": 1,
33
"label": "size",
4-
"message": "4.4 kB brotli",
4+
"message": "4.6 kB brotli",
55
"color": "brightgreen"
66
}

docs/src/pages/api/content.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,70 @@ html\`<button disabled="\${() => data.loading ? '' : false}">Submit<
415415
`
416416
}
417417

418+
export function SvgApi() {
419+
return html`
420+
<section id="svg" class="mb-16">
421+
<h2
422+
class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white mb-4"
423+
>
424+
svg
425+
</h2>
426+
<div class="space-y-4 text-zinc-600 dark:text-zinc-400 leading-relaxed">
427+
<p>
428+
Tagged template literal for SVG child templates. It returns an
429+
<code>ArrowTemplate</code> just like <code>html</code>, but parses the
430+
template in the SVG namespace.
431+
</p>
432+
433+
<h3
434+
class="text-lg font-semibold text-zinc-900 dark:text-white pt-4"
435+
>
436+
Signature
437+
</h3>
438+
${TsCodeBlock(`import type { ArrowExpression, ArrowTemplate } from '@arrow-js/core'
439+
440+
declare function svg(
441+
strings: TemplateStringsArray | string[],
442+
...expSlots: ArrowExpression[]
443+
): ArrowTemplate`)}
444+
445+
<h3
446+
class="text-lg font-semibold text-zinc-900 dark:text-white pt-4"
447+
>
448+
When to use
449+
</h3>
450+
<p>
451+
Use <code>svg</code> when you need to nest SVG elements like
452+
<code>&lt;rect&gt;</code>, <code>&lt;circle&gt;</code>, or
453+
<code>&lt;path&gt;</code> as child templates inside an
454+
<code>&lt;svg&gt;</code>. A nested <code>html</code> template parses in
455+
HTML mode and will not create SVG nodes correctly.
456+
</p>
457+
458+
<div class="code-block">
459+
<pre><code class="language-ts">import { html, svg } from '@arrow-js/core'
460+
461+
html\`&lt;svg width="100" height="100" viewBox="0 0 100 100"&gt;
462+
\${() =&gt; data.values.map((v, i) =&gt; svg\`&lt;rect
463+
x="\${i * 10}"
464+
y="\${100 - v}"
465+
width="9"
466+
height="\${v}"
467+
fill="red"
468+
/&gt;\`)}
469+
&lt;/svg&gt;\`</code></pre>
470+
</div>
471+
472+
<p>
473+
<code>svg</code> uses the same mounting, reactivity, list rendering,
474+
keys, hydration, and cleanup behavior as <code>html</code>. The
475+
difference is only the parse namespace.
476+
</p>
477+
</div>
478+
</section>
479+
`
480+
}
481+
418482
export function ComponentApi() {
419483
return html`
420484
<section id="component" class="mb-16">
@@ -1353,6 +1417,10 @@ export const HighlightedHtmlApi = highlightedSection(
13531417
HtmlApi,
13541418
'api-html'
13551419
)
1420+
export const HighlightedSvgApi = highlightedSection(
1421+
SvgApi,
1422+
'api-svg'
1423+
)
13561424
export const HighlightedComponentApi = highlightedSection(
13571425
ComponentApi,
13581426
'api-component'

docs/src/pages/api/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
HighlightedWatchApi,
1010
HtmlApi,
1111
HighlightedHtmlApi,
12+
SvgApi,
13+
HighlightedSvgApi,
1214
ComponentApi,
1315
HighlightedComponentApi,
1416
OnCleanupApi,
@@ -45,6 +47,7 @@ export function ApiPage(options: { highlightCode?: boolean } = {}) {
4547
const ReactiveApiSection = highlightCode ? HighlightedReactiveApi : ReactiveApi
4648
const WatchApiSection = highlightCode ? HighlightedWatchApi : WatchApi
4749
const HtmlApiSection = highlightCode ? HighlightedHtmlApi : HtmlApi
50+
const SvgApiSection = highlightCode ? HighlightedSvgApi : SvgApi
4851
const ComponentApiSection = highlightCode ? HighlightedComponentApi : ComponentApi
4952
const OnCleanupApiSection = highlightCode ? HighlightedOnCleanupApi : OnCleanupApi
5053
const PickApiSection = highlightCode ? HighlightedPickApi : PickApi
@@ -82,6 +85,7 @@ export function ApiPage(options: { highlightCode?: boolean } = {}) {
8285
</div>
8386
<h2 class="text-sm font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500 border-b border-zinc-200 dark:border-zinc-800 pb-2 mb-8 mt-4">@arrow-js/core</h2>
8487
${ReactiveApiSection()} ${WatchApiSection()} ${HtmlApiSection()}
88+
${SvgApiSection()}
8589
${ComponentApiSection()} ${OnCleanupApiSection()} ${PickApiSection()}
8690
${NextTickApiSection()}
8791

docs/src/pages/api/nav.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const apiNavGroups: NavGroup[] = [
77
{ id: 'reactive', label: 'reactive()' },
88
{ id: 'watch', label: 'watch()' },
99
{ id: 'html', label: 'html' },
10+
{ id: 'svg', label: 'svg' },
1011
{ id: 'component', label: 'component()' },
1112
{ id: 'on-cleanup', label: 'onCleanup()' },
1213
{ id: 'pick', label: 'pick() / props()' },

packages/core/src/__tests__/html.spec.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { html, reactive, nextTick, ArrowTemplate } from '..'
1+
import { html, svg, reactive, nextTick, ArrowTemplate } from '..'
22
import { click, setValue } from './utils/events'
33
import { describe, it, expect, vi } from 'vitest'
44
import {
@@ -1279,6 +1279,52 @@ describe('html', () => {
12791279
expect(parent.innerHTML).toMatchSnapshot()
12801280
})
12811281

1282+
it('renders nested svg templates in the svg namespace', () => {
1283+
const parent = document.createElement('div')
1284+
const values = [40, 20]
1285+
html`<svg width="100" height="100" viewBox="0 0 100 100">
1286+
${values.map(
1287+
(value, index) => svg`<rect
1288+
x="${index * 10}"
1289+
y="${100 - value}"
1290+
width="9"
1291+
height="${value}"
1292+
fill="red"
1293+
/>`
1294+
)}
1295+
</svg>`(parent)
1296+
1297+
const rects = Array.from(parent.querySelectorAll('rect'))
1298+
expect(rects).toHaveLength(2)
1299+
expect(rects[0].namespaceURI).toBe('http://www.w3.org/2000/svg')
1300+
expect(rects[0].getAttribute('fill')).toBe('red')
1301+
})
1302+
1303+
it('updates svg template lists reactively', async () => {
1304+
const parent = document.createElement('div')
1305+
const data = reactive({ values: [25] })
1306+
html`<svg width="100" height="100" viewBox="0 0 100 100">
1307+
${() =>
1308+
data.values.map(
1309+
(value, index) => svg`<rect
1310+
x="${index * 10}"
1311+
y="${100 - value}"
1312+
width="9"
1313+
height="${value}"
1314+
fill="red"
1315+
/>`
1316+
)}
1317+
</svg>`(parent)
1318+
1319+
data.values = [25, 50]
1320+
await nextTick()
1321+
1322+
const rects = Array.from(parent.querySelectorAll('rect'))
1323+
expect(rects).toHaveLength(2)
1324+
expect(rects[1].namespaceURI).toBe('http://www.w3.org/2000/svg')
1325+
expect(rects[1].getAttribute('height')).toBe('50')
1326+
})
1327+
12821328
// it('renders sanitized HTML when reading from a variable.', () => {
12831329
// const data = reactive({
12841330
// foo: '<h1>Hello world</h1>',

packages/core/src/html.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -186,28 +186,44 @@ function getChunkProto(template: InternalTemplate): ChunkProto {
186186
return (template._p = resolveChunkProto(template._s as string[]))
187187
}
188188

189-
function resolveChunkProto(rawStrings: TemplateStringsArray | string[]): ChunkProto {
189+
function resolveChunkProto(
190+
rawStrings: TemplateStringsArray | string[],
191+
svg?: boolean
192+
): ChunkProto {
190193
const doc = document
191-
let memoByRef = chunkMemoByRef.get(rawStrings)
194+
let memoByRef = svg ? undefined : chunkMemoByRef.get(rawStrings)
192195
const cachedByRef = memoByRef?.get(doc)
193196
if (cachedByRef) return cachedByRef
194197

195198
const signature = rawStrings.join(delimiterComment)
199+
const cacheKey = svg ? `${delimiter}${signature}` : signature
196200
let signatureMemo = chunkMemo.get(doc)
197201
if (!signatureMemo) {
198202
signatureMemo = {}
199203
chunkMemo.set(doc, signatureMemo)
200204
}
201-
const cached = signatureMemo[signature]
205+
const cached = signatureMemo[cacheKey]
202206
if (cached) {
203-
memoByRef ??= new WeakMap<Document, ChunkProto>()
204-
memoByRef.set(doc, cached)
205-
chunkMemoByRef.set(rawStrings, memoByRef)
207+
if (!svg) {
208+
memoByRef ??= new WeakMap<Document, ChunkProto>()
209+
memoByRef.set(doc, cached)
210+
chunkMemoByRef.set(rawStrings, memoByRef)
211+
}
206212
return cached
207213
}
208214

209215
const template = document.createElement('template')
210-
template.innerHTML = signature
216+
if (svg) {
217+
template.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${signature}</svg>`
218+
const root = template.content.firstChild as SVGElement | null
219+
if (root) {
220+
const content = template.content
221+
while (root.firstChild) content.appendChild(root.firstChild)
222+
content.removeChild(root)
223+
}
224+
} else {
225+
template.innerHTML = signature
226+
}
211227
const paths = createPaths(template.content)
212228
normalizeNodePlaceholders(template.content)
213229
const expressions = rawStrings.length - 1
@@ -222,13 +238,15 @@ function resolveChunkProto(rawStrings: TemplateStringsArray | string[]): ChunkPr
222238
const created = {
223239
template,
224240
paths,
225-
g: signature,
241+
g: cacheKey,
226242
expressions,
227243
}
228-
memoByRef ??= new WeakMap<Document, ChunkProto>()
229-
memoByRef.set(doc, created)
230-
chunkMemoByRef.set(rawStrings, memoByRef)
231-
signatureMemo[signature] = created
244+
if (!svg) {
245+
memoByRef ??= new WeakMap<Document, ChunkProto>()
246+
memoByRef.set(doc, created)
247+
chunkMemoByRef.set(rawStrings, memoByRef)
248+
}
249+
signatureMemo[cacheKey] = created
232250
return created
233251
}
234252

@@ -409,6 +427,19 @@ export function html(
409427
return template
410428
}
411429

430+
export function svg(
431+
strings: TemplateStringsArray | string[],
432+
...expSlots: ArrowExpression[]
433+
): ArrowTemplate
434+
export function svg(
435+
strings: TemplateStringsArray | string[],
436+
...expSlots: ArrowExpression[]
437+
): ArrowTemplate {
438+
const template = html(strings, ...expSlots) as InternalTemplate
439+
template._p = resolveChunkProto(strings, true)
440+
return template
441+
}
442+
412443
function ensureChunk(this: InternalTemplate) {
413444
let chunk = this._h
414445
if (!chunk) {

packages/core/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { component, pick } from './component'
2-
import { html } from './html'
2+
import { html, svg } from './html'
33
import { reactive, watch } from './reactive'
44
import { nextTick, onCleanup } from './common'
55

66
export {
77
component,
88
component as c,
99
html,
10+
svg,
1011
html as t,
1112
pick,
1213
pick as props,

0 commit comments

Comments
 (0)