a post-component library for building user-interfaces on the web.
1import { assert, lexer } from '../shared.ts'
2import { is_comment, is_document_fragment, is_element } from './util.ts'
3
4export const PART_CHILD = 0
5export const PART_DIRECTIVE = 1
6export const PART_ATTRIBUTE = 2
7export const PART_PROPERTY = 3
8
9export type PartData =
10 | [type: typeof PART_CHILD, index: number]
11 | [type: typeof PART_DIRECTIVE]
12 | [type: typeof PART_ATTRIBUTE, name: string]
13 | [type: typeof PART_PROPERTY, name: string]
14
15export interface CompiledTemplate {
16 _content: DocumentFragment
17 _parts: [idx: number, PartData][]
18 _root_parts: number[]
19}
20
21export const DYNAMIC_WHOLE: RegExp = /^dyn-\$(\d+)\$$/
22const DYNAMIC_GLOBAL = /dyn-\$(\d+)\$/g
23const FORCE_ATTRIBUTES = /-|^class$|^for$/i
24const NEEDS_UPPERCASING = /\$./g
25
26const templates: WeakMap<TemplateStringsArray, CompiledTemplate> = new WeakMap()
27export function compile_template(statics: TemplateStringsArray): CompiledTemplate {
28 const cached = templates.get(statics)
29 if (cached) return cached
30
31 const template_element = document.createElement('template')
32 let next_part = 0
33 template_element.innerHTML = [...lexer.lex(statics)]
34 .map(([char, state]) => {
35 if (char === '\0') {
36 if (state === lexer.DATA) return `<!--dyn-$${next_part++}$-->`
37 else return `dyn-$${next_part++}$`
38 }
39 if (state === lexer.ATTR_NAME && char.toLowerCase() !== char) {
40 return `$${char}`
41 }
42 return char
43 })
44 .join('')
45
46 next_part = 0
47
48 const compiled: CompiledTemplate = {
49 _content: template_element.content,
50 _parts: Array(statics.length - 1),
51 _root_parts: [],
52 }
53
54 function patch(node: DocumentFragment | HTMLElement | SVGElement, idx: number, data: PartData) {
55 assert(next_part < compiled._parts.length, 'got more parts than expected')
56 if (is_document_fragment(node)) compiled._root_parts.push(next_part)
57 else if ('dynparts' in node.dataset) node.dataset.dynparts += ' ' + next_part
58 // @ts-expect-error -- this assigment will cast nextPart to a string
59 else node.dataset.dynparts = next_part
60 compiled._parts[next_part++] = [idx, data]
61 }
62
63 const walker = document.createTreeWalker(template_element.content, 129)
64 assert((NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT) === 129)
65
66 while (walker.nextNode()) {
67 const node = walker.currentNode
68 if (is_comment(node)) {
69 const match = DYNAMIC_WHOLE.exec(node.data)
70 if (match !== null) {
71 const parent_node = node.parentNode
72 assert(parent_node !== null, 'all text nodes should have a parent node')
73 assert(
74 parent_node instanceof DocumentFragment ||
75 parent_node instanceof HTMLElement ||
76 parent_node instanceof SVGElement,
77 )
78
79 // these will become the start and end of the span:
80 parent_node.insertBefore(new Text(), node)
81 parent_node.insertBefore(new Text(), node.nextSibling)
82
83 patch(parent_node, parseInt(match[1]), [PART_CHILD, [...parent_node.childNodes].indexOf(node)])
84 }
85 } else {
86 assert(is_element(node))
87 assert(node instanceof HTMLElement || node instanceof SVGElement)
88
89 for (const name of node.getAttributeNames()) {
90 const value = node.getAttribute(name)
91 assert(value !== null)
92
93 let match = DYNAMIC_WHOLE.exec(name)
94 if (match !== null) {
95 // directive:
96 node.removeAttribute(name)
97 assert(value === '', `directives must not have values`)
98 patch(node, parseInt(match[1]), [PART_DIRECTIVE])
99 } else {
100 // properties:
101 match = DYNAMIC_WHOLE.exec(value)
102 const remapped_name = name.replace(NEEDS_UPPERCASING, match => match[1].toUpperCase())
103 if (match !== null) {
104 node.removeAttribute(name)
105 if (FORCE_ATTRIBUTES.test(remapped_name)) {
106 patch(node, parseInt(match[1]), [PART_ATTRIBUTE, remapped_name])
107 } else {
108 patch(node, parseInt(match[1]), [PART_PROPERTY, remapped_name])
109 }
110 } else if (remapped_name !== name) {
111 assert(!node.hasAttribute(remapped_name), `duplicate attribute ${remapped_name}`)
112 node.setAttribute(remapped_name, value)
113 node.removeAttribute(name)
114 } else {
115 assert(
116 !DYNAMIC_GLOBAL.test(value),
117 `expected a whole dynamic value for ${remapped_name}, got a partial one`,
118 )
119 }
120 }
121 }
122 }
123 }
124
125 compiled._parts.length = next_part
126
127 templates.set(statics, compiled)
128 return compiled
129}