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:

+ + ${svg /*xml*/ ` + + + `} + + +

MathML Elements:

+
+ + ${mathml /*xml*/ ` + + x + y + + `} + +
+`(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 = `${htmlString}` + else if (mode === 'mathml') htmlString = `${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 `