diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 4edaf4f..130e2e2 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1 +1,2 @@
-Follow instructions from the repo's top-level CONTRIBUTING.md.
+Always, always, read the repo's top-level CONTRIBUTING.md file before doing any
+work, and follow its instructions.
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..331b155
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,29 @@
+name: Test
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 'lts/*'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm clean-install
+
+ - name: Run tests
+ run: npm test
diff --git a/.prettierrc.cjs b/.prettierrc.cjs
index 383f0dd..df25309 100644
--- a/.prettierrc.cjs
+++ b/.prettierrc.cjs
@@ -6,6 +6,7 @@ module.exports = {
bracketSpacing: false,
printWidth: 120,
arrowParens: 'avoid',
+ endOfLine: 'auto',
overrides: [{files: '*.md', options: {useTabs: false}}],
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fa25c41..a8815fd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,7 +10,75 @@ Do not install any new dependencies.
# Testing
Run `npm test` to test everything. This runs the build, checks code formatting,
-and finally runs unit tests.
+and finally runs unit tests. Never run any other test command than `npm test`.
When writing new tests, always follow the format in `html.test.js` as a
reference, using `describe` and `it` functions to describe unit tests.
+
+# Best Practices
+
+- Don't constantly re-create RegExp objects, put them in a variable outside of
+ functions so they are only created once (f.e. at the top level scope of the
+ module).
+- Always aim for the simplest _readable, understandable_ solution.
+ - If you're adding too much code, you might be solving the problem in a too complex way.
+ - Put re-usable code in functions, always avoid duplication.
+ - Don't use complex one-liners or clever bit fiddling unless it is absolutely
+ necessary for something like solving a performance _problem_, prefer multiple
+ simple readable lines.
+ - Don't prematurely optimize, always prefer readable code first.
+ - Document your code with JSDoc comments. All functions, methods, and classes
+ should have JSDoc comments.
+- Avoid unnecessary braces. For example for conditional or loop blocks with a single statement, prefer:
+ ```js
+ if (condition) doSomething()
+ ```
+ instead of:
+ ```js
+ if (condition) {
+ doSomething()
+ }
+ ```
+ Similar with for loops, while loops, arrow functions.
+- Use new features such as optional chaining (`obj?.prop`), nullish coalescing
+ (`value ?? defaultValue`), and logical assignment operators (`x ||= y`, `x &&= y`,
+ `x ??= y`) when they make the code simpler and more readable.
+- Always prefer `const` for variables that don't change, and `let` only for
+ variables that change. Never use `var` unless absolutely necessary for special
+ hoisting reasons.
+- Always prefer for-of over items.forEach
+- Always prefer `element.remove()` instead of `element.parentNode.removeChild(element)`.
+- Always prefer `parentElement.append(childElement)` instead of
+ `parentElement.appendChild(childElement)`.
+- Always prefer `parentElement.append(...childElements)` instead of
+ `childElements.forEach(child => parentElement.appendChild(child))`.
+- Always prefer `parentElement.prepend(childElement)` instead of
+ `parentElement.insertBefore(childElement, parentElement.firstChild)`.
+- Always prefer `element.replaceWith(newElement)` instead of
+ `element.parentNode.replaceChild(newElement, element)`.
+
+# AI only:
+
+## Never do the following:
+
+- Never create tests in files that you run with `node`. Our code is for
+ browsers, so always run tests using `npm test` which ensures our tests run in a
+ headless browser. The output is logged back to terminal.
+
+## Responding to prompts
+
+After every prompt, always provide at least three proposals for a solution, with
+pros and cons, and stop to allow the user to select the desired direction.
+
+A conversation should be like this:
+
+1. User: Let's do [thing to do].
+2. AI: Here are three ways we could do X:
+ 1. Do it this way because of A, B, C. Pros: ... Cons: ...
+ 2. Do it that way because of D, E, F. Pros: ... Cons: ...
+ 3. Do it another way because of G, H, I. Pros: ... Cons: ...
+3. User: Let's go with option 2.
+4. AI: Great! (AI goes and implements option 2)
+5. Repeat from step 1.
+
+Basically, _always_ confirm with three proposals before implementing anything.
diff --git a/examples/svg-and-mathml.html b/examples/svg-and-mathml.html
new file mode 100644
index 0000000..e9e1734
--- /dev/null
+++ b/examples/svg-and-mathml.html
@@ -0,0 +1,9 @@
+
+
+
+ SVG Test
+
+
+
+
+
diff --git a/examples/svg-and-mathml.js b/examples/svg-and-mathml.js
new file mode 100644
index 0000000..2583141
--- /dev/null
+++ b/examples/svg-and-mathml.js
@@ -0,0 +1,71 @@
+import {html, svg, mathml} from '../html.js'
+
+const template = html /*html*/ `
+
+
+ Showing SVG and MathML support. This example shows the svg and mathml template tags can
+ be used to create SVG and MathML elements within HTML templates. Replacing the svg and
+ mathml template tags with html will result in HTMLUnknownElement instances due to the
+ partial templates being parsed as HTML instead of SVG or MathML.
+
+
+
+
+
Regular HTML:
+
This is a regular div
+
+
SVG Elements:
+
+
+
MathML Elements:
+
+
+
+`(Symbol())
+
+document.body.append(...template)
+
+// Let's inspect the elements
+const div = document.querySelector('.my-div')
+const svgEl = document.querySelector('svg')
+const circle = document.querySelector('circle')
+const rect = document.querySelector('rect')
+const mathEl = document.querySelector('math')
+const mfrac = document.querySelector('mfrac')
+
+console.log('Regular div:', div, div?.constructor.name) // HTMLDivElement
+console.log('SVG element:', svgEl, svgEl?.constructor.name) // SVGSVGElement
+console.log('Circle element:', circle, circle?.constructor.name) // SVGCircleElement
+console.log('Rect element:', rect, rect?.constructor.name) // SVGRectElement
+console.log('MathML element:', mathEl, mathEl?.constructor.name) // MathMLElement
+console.log('mfrac element:', mfrac, mfrac?.constructor.name) // MathMLFractionElement
+
+// Check if SVG elements are actually SVG elements
+console.log('div instanceof HTMLDivElement:', div instanceof HTMLDivElement) // true
+console.log('svg instanceof SVGSVGElement:', svgEl instanceof SVGSVGElement) // true
+console.log('circle instanceof SVGCircleElement:', circle instanceof SVGCircleElement) // true
+console.log('rect instanceof SVGRectElement:', rect instanceof SVGRectElement) // true
+console.log('math instanceof MathMLElement:', mathEl instanceof MathMLElement) // true
+console.log('mfrac instanceof MathMLFractionElement:', mfrac instanceof MathMLElement) // true
+
+// Check if they're unknown elements
+console.log('circle instanceof HTMLUnknownElement:', circle instanceof HTMLUnknownElement) // false
+console.log('rect instanceof HTMLUnknownElement:', rect instanceof HTMLUnknownElement) // false
+console.log('mfrac instanceof HTMLUnknownElement:', mfrac instanceof HTMLUnknownElement) // false
diff --git a/html.js b/html.js
index 937877e..34d1315 100644
--- a/html.js
+++ b/html.js
@@ -1,6 +1,5 @@
/**
- * Framework-agnostic tiny `html` template tag function for declarative DOM
- * creation and updates.
+ * A nimble `html` template tag function for declarative DOM creation and updates.
*
* @param {TemplateStringsArray} strings
* @param {...InterpolationValue} values
@@ -9,7 +8,45 @@
* values.
*/
export function html(strings, ...values) {
- const template = parseTemplate(strings)
+ return handleTemplateTag('html', strings, ...values)
+}
+
+/**
+ * A nimble `svg` template tag function for declarative SVG DOM creation and updates.
+ *
+ * @param {TemplateStringsArray} strings
+ * @param {...InterpolationValue} values
+ * @returns {(key: any) => TemplateNodes} A function that accepts a key for
+ * template instance identity, and returns SVG DOM nodes rendered with the given
+ * values.
+ */
+export function svg(strings, ...values) {
+ return handleTemplateTag('svg', strings, ...values)
+}
+
+/**
+ * A nimble `mathml` template tag function for declarative MathML DOM creation and updates.
+ *
+ * @param {TemplateStringsArray} strings
+ * @param {...InterpolationValue} values
+ * @returns {(key: any) => TemplateNodes} A function that accepts a key for
+ * template instance identity, and returns MathML DOM nodes rendered with the given
+ * values.
+ */
+export function mathml(strings, ...values) {
+ return handleTemplateTag('mathml', strings, ...values)
+}
+
+/**
+ * @param {TemplateMode} mode
+ * @param {TemplateStringsArray} strings
+ * @param {...InterpolationValue} values
+ * @returns {(key: any) => TemplateNodes} A function that accepts a key for
+ * template instance identity, and returns DOM nodes rendered with the given
+ * values.
+ */
+function handleTemplateTag(mode, strings, ...values) {
+ const template = parseTemplate(strings, mode)
const useFunctionWrapper = true
@@ -36,8 +73,10 @@ const INTERPOLATION_MARKER = '⧙⧘'
/** RegExp for matching interpolation markers */
const INTERPOLATION_REGEXP = new RegExp(`${INTERPOLATION_MARKER}(\\d+)${INTERPOLATION_MARKER}`)
-/** This regex matches . followed by a JS identifier. TODO improve to match actual JS identifiers. */
-const JS_PROP_REGEXP = /\.([A-Za-z][A-Za-z0-9]*)/g
+/** Regex for finding HTML opening/self-closing tags */
+const HTML_TAG_REGEXP = /<[^<>]*?\/?>/g
+
+const ATTRIBUTE_END_REGEXP = /[\s=\/>]/
/**
* Parse parts array, converting alternating indices to numbers
@@ -111,15 +150,21 @@ class Template {
caseMappings = new Map()
/**
+ * @param {TemplateMode} mode
* @param {TemplateStringsArray} strings
*/
- constructor(strings) {
+ constructor(strings, mode) {
// Join strings with interpolation markers
let htmlString = strings.reduce(
(acc, str, i) => acc + str + (i < strings.length - 1 ? `${INTERPOLATION_MARKER}${i}${INTERPOLATION_MARKER}` : ''),
'',
)
+ // Wrap content in appropriate root elements for SVG and MathML modes
+ // so that the HTML parser creates elements with correct namespaces
+ if (mode === 'svg') htmlString = ``
+ else if (mode === 'mathml') htmlString = ``
+
const {caseMappings, el} = this
// Preprocessing for case sensitivity: map .someProp to .someprop and remember the original
@@ -134,16 +179,67 @@ class Template {
// 3. This allows us to avoid issues with HTML attribute names being
// case-insensitive, while still preserving the original case for JS
// property names so we can set them correctly on the elements.
- htmlString = htmlString.replace(JS_PROP_REGEXP, (_, propName) => {
- const placeholder = `.case-preserved${counter}`
- caseMappings.set(placeholder.slice(1), propName) // Store without the dot
- counter++
- return placeholder
+
+ // Scan for HTML tags and process .property attributes within each tag
+ htmlString = htmlString.replace(HTML_TAG_REGEXP, tagMatch => {
+ // Parse the tag content more carefully to avoid matching dots inside quoted attribute values
+ const parts = []
+ let lastIndex = 0
+ let inQuotes = false
+ let quoteChar = ''
+ let i = 0
+
+ while (i < tagMatch.length) {
+ const char = tagMatch[i]
+
+ if (!inQuotes && (char === '"' || char === "'")) {
+ inQuotes = true
+ quoteChar = char
+ } else if (inQuotes && char === quoteChar) {
+ inQuotes = false
+ quoteChar = ''
+ } else if (!inQuotes && (char === '.' || char === '@') && i > 0 && /\s/.test(tagMatch[i - 1])) {
+ // Found a dot or @ that's not inside quotes and is preceded by whitespace (attribute name)
+ // Scan forward to find the end of the attribute name
+ let attrEnd = i + 1
+ while (attrEnd < tagMatch.length && !ATTRIBUTE_END_REGEXP.test(tagMatch[attrEnd])) attrEnd++
+
+ if (attrEnd > i + 1) {
+ // We found at least one character after the dot/at symbol
+ const attrName = tagMatch.slice(i + 1, attrEnd) // Extract attribute name without the prefix
+ const placeholder = `${char}case-preserved${counter}`
+ caseMappings.set(placeholder.slice(1), attrName)
+ counter++
+
+ // Add the part before this replacement
+ parts.push(tagMatch.slice(lastIndex, i))
+ // Add the placeholder
+ parts.push(placeholder)
+ // Update tracking
+ lastIndex = attrEnd
+ i = attrEnd - 1 // -1 because the loop will increment
+ }
+ }
+ i++
+ }
+
+ // Add the remaining part
+ parts.push(tagMatch.slice(lastIndex))
+
+ return parts.join('')
})
// Use the standard HTML parser to parse the string into a template document
el.innerHTML = htmlString
+ // For SVG and MathML templates, unwrap the content from the wrapper
+ // element that was added during parsing, and remove the wrapper, to
+ // ensure proper namespace handling.
+ if (mode === 'svg' || mode === 'mathml') {
+ const wrapperElement = /** @type {Element} */ (el.content.firstElementChild)
+ wrapperElement.replaceWith(...wrapperElement.childNodes)
+ }
+
// Pre-split text nodes that contain interpolation markers
// This is done once during template creation for better performance
splitTextNodesWithInterpolation(el.content)
@@ -205,7 +301,7 @@ class Template {
for (const node of el.content.childNodes) {
if (node.nodeType !== Node.TEXT_NODE) continue
if (!(node.textContent || '').includes(INTERPOLATION_MARKER) && (node.textContent || '').trim() === '')
- node.parentNode?.removeChild(node)
+ node.remove()
}
}
@@ -260,15 +356,16 @@ class Template {
* the HTML for cloning into "template instances", and other data, associated
* with the given template strings.
*
+ * @param {TemplateMode} mode
* @param {TemplateStringsArray} strings
*
* @returns {Template} The Template instance contains a `` element
* with the DOM representation of the HTML, with interpolation markers in place,
* to be cloned when we create any "instance" of the template.
*/
-function parseTemplate(strings) {
+function parseTemplate(strings, mode) {
let template = templateCache.get(strings)
- if (!template) templateCache.set(strings, (template = new Template(strings)))
+ if (!template) templateCache.set(strings, (template = new Template(strings, mode)))
return template
}
@@ -335,7 +432,8 @@ function findInterpolationSites(fragment, caseMappings) {
processedName = caseMappings.get(placeholder) || placeholder
} else if (name.startsWith('@')) {
type = 'event'
- processedName = name.slice(1) // Remove the at symbol
+ const placeholder = name.slice(1) // Remove the at symbol
+ processedName = caseMappings.get(placeholder) || placeholder
}
sites.push({node: element, type, attributeName: processedName, parts: parsedParts})
@@ -442,7 +540,6 @@ function interpolateTextSite(/** @type {InterpolationSite} */ site, /** @type {I
// template functions at the same site but different positions don't share cache entries,
// even when using the same mapper function (e.g., html`
${items.map(itemMapper)}
`).
const stableKey = getStableNestedKey(site, index)
- console.log('calling nested template function with stable key', stableKey)
const result = item(stableKey)
return Array.isArray(result) ? result : [result]
}
@@ -489,15 +586,11 @@ class TemplateInstance {
applyValues(values) {
const sites = this.sites
- console.log('applying values to template instance', values)
-
for (const site of sites) {
if (site.type === 'text') {
- console.log('interpolating text site', site)
// With pre-split text nodes, each text site corresponds to exactly one interpolation
if (site.interpolationIndex !== undefined) {
const value = values[site.interpolationIndex]
- console.log('interpolating text site with value', value)
interpolateTextSite(site, value)
}
} else if (site.type === 'attribute') {
@@ -634,3 +727,5 @@ class TemplateInstance {
* currentEventListener?: EventListener
* }} InterpolationSite
*/
+
+/** @typedef { 'html' | 'svg' | 'mathml'} TemplateMode */
diff --git a/test/html.test.js b/test/html.test.js
index 42e0e71..56e2b67 100644
--- a/test/html.test.js
+++ b/test/html.test.js
@@ -1,4 +1,4 @@
-import {html} from '../html.js'
+import {html, svg, mathml} from '../html.js'
/**
* @param {any} actual
@@ -1105,4 +1105,483 @@ describe('html template function', () => {
container.remove()
})
+
+ it('handles case-sensitive property names', () => {
+ const key = Symbol()
+ let value = 'test'
+
+ // Create element with both .customprop and .customProp bindings
+ const tmpl = () => html``(key)
+
+ const [el] = /** @type {[any]} */ (tmpl())
+
+ // Should set two different JS properties (JS properties are case sensitive)
+ assertEquals(el.customprop, 'test1', 'Should set customprop (lowercase) property')
+ assertEquals(el.customProp, 'test2', 'Should set customProp (camelCase) property')
+
+ // Update value and verify both properties update independently
+ value = 'updated'
+ tmpl()
+ assertEquals(el.customprop, 'updated1', 'Should update customprop property')
+ assertEquals(el.customProp, 'updated2', 'Should update customProp property')
+ })
+
+ it('handles case-sensitive event names', () => {
+ const key = Symbol()
+ /** @type {string[]} */
+ let eventsCalled = []
+
+ const someeventHandler = () => eventsCalled.push('someevent')
+ const someEventHandler = () => eventsCalled.push('someEvent')
+
+ // Create element with both @someevent and @someEvent bindings
+ const tmpl = () => html``(key)
+
+ const [button] = /** @type {[HTMLButtonElement]} */ (tmpl())
+
+ // Dispatch both event types
+ button.dispatchEvent(new Event('someevent'))
+ button.dispatchEvent(new Event('someEvent'))
+
+ assertEquals(eventsCalled.length, 2, 'Should handle two different events')
+ assertEquals(eventsCalled[0], 'someevent', 'Should call someevent handler')
+ assertEquals(eventsCalled[1], 'someEvent', 'Should call someEvent handler')
+ })
+
+ it(`properly detects case-sensitive property bindings in attribute names,
+ and not erroenously in attribute values or text content`, () => {
+ const key = Symbol()
+ let value = 'propValue'
+
+ const [div] = /** @type {[HTMLDivElement & { someProp: string }]} */ (
+ html`
+ Text with .someProp=${value} inside
+
`(key)
+ )
+
+ // Should set the property correctly
+ assertEquals(div.someProp, 'propValue', 'Should set someProp property correctly')
+
+ // But should not set any attributes or text content with the property binding syntax
+ assertEquals(div.getAttribute('data-attr'), '.someProp=propValue', 'data-attr should contain literal text')
+ assertEquals(div.getAttribute('title'), 'This is a .someProp=propValue test', 'title should contain literal text')
+ assertTrue(
+ div.textContent?.includes('Text with .someProp=propValue inside'),
+ 'Text content should contain literal text',
+ )
+ })
+
+ it('handles property names with non-identifier characters', () => {
+ const key = Symbol()
+
+ // Test numeric property name
+ const [div1] = /** @type {[HTMLDivElement & { '123': string }]} */ (html``(key))
+ assertEquals(div1['123'], 'foo', 'Should set numeric property name')
+
+ // Test property name with special characters
+ const [div2] = /** @type {[HTMLDivElement & { '#@!': string }]} */ (html``(key))
+ assertEquals(div2['#@!'], 'blah', 'Should set property name with special characters')
+
+ // Test property name with mixed characters
+ const [div3] = /** @type {[HTMLDivElement & { 'my-custom_prop.#123': string }]} */ (
+ html``(key)
+ )
+ assertEquals(div3['my-custom_prop.#123'], 'test', 'Should set property name with mixed characters')
+ })
+
+ it('handles event names with non-identifier characters', () => {
+ const key = Symbol()
+ /** @type {string[]} */
+ let eventsCalled = []
+
+ // Test numeric event name
+ const [div1] = /** @type {[HTMLDivElement]} */ (html`
eventsCalled.push('123')}>
`(key))
+ div1.dispatchEvent(new Event('123'))
+ assertEquals(eventsCalled[0], '123', 'Should handle numeric event name')
+
+ // Test event name with special characters
+ const [div2] = /** @type {[HTMLDivElement]} */ (html`
eventsCalled.push('$#@!')}>
`(key))
+ div2.dispatchEvent(new Event('$#@!'))
+ assertEquals(eventsCalled[1], '$#@!', 'Should handle event name with special characters')
+
+ // Test event name with mixed characters
+ const [div3] = /** @type {[HTMLDivElement]} */ (
+ html`