diff --git a/.gitignore b/.gitignore index 844fe16..4e0ac6a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ worker.js keyed.js !esm/keyed.js !esm/dom/keyed.js +hydro.js +!esm/hydro.js +!esm/dom/hydro.js index.js !esm/index.js !esm/dom/index.js diff --git a/esm/dom/document.js b/esm/dom/document.js index 53a1604..a837a83 100644 --- a/esm/dom/document.js +++ b/esm/dom/document.js @@ -2,7 +2,7 @@ import { DOCUMENT_NODE } from 'domconstants/constants'; import { setParentNode } from './utils.js'; -import { childNodes, documentElement, nodeName, ownerDocument } from './symbols.js'; +import { childNodes, documentElement, nodeName, ownerDocument, __chunks__ } from './symbols.js'; import Attribute from './attribute.js'; import Comment from './comment.js'; @@ -33,6 +33,7 @@ export default class Document extends Parent { this[doctype] = null; this[head] = null; this[body] = null; + this[__chunks__] = false; if (type === 'html') { const html = (this[documentElement] = new Element(type, this)); this[childNodes] = [ diff --git a/esm/dom/symbols.js b/esm/dom/symbols.js index 6e5bd52..2855b1a 100644 --- a/esm/dom/symbols.js +++ b/esm/dom/symbols.js @@ -9,3 +9,4 @@ export const parentNode = Symbol('parentNode'); export const attributes = Symbol('attributes'); export const name = Symbol('name'); export const value = Symbol('value'); +export const __chunks__ = Symbol(); diff --git a/esm/dom/text.js b/esm/dom/text.js index 7815238..ed78dc6 100644 --- a/esm/dom/text.js +++ b/esm/dom/text.js @@ -3,7 +3,7 @@ import { TEXT_ELEMENTS } from 'domconstants/re'; import { escape } from 'html-escaper'; import CharacterData from './character-data.js'; -import { parentNode, localName, ownerDocument, value } from './symbols.js'; +import { parentNode, localName, ownerDocument, value, __chunks__ } from './symbols.js'; export default class Text extends CharacterData { constructor(data = '', owner = null) { @@ -17,6 +17,10 @@ export default class Text extends CharacterData { toString() { const { [parentNode]: parent, [value]: data } = this; return parent && TEXT_ELEMENTS.test(parent[localName]) ? - data : escape(data); + data : + (this[ownerDocument]?.[__chunks__] && this.previousSibling?.nodeType === TEXT_NODE ? + `${escape(data)}` : + escape(data) + ); } } diff --git a/esm/hydro.js b/esm/hydro.js new file mode 100644 index 0000000..432a229 --- /dev/null +++ b/esm/hydro.js @@ -0,0 +1,118 @@ +import { COMMENT_NODE, TEXT_NODE } from 'domconstants/constants'; +import { abc, cache, detail } from './literals.js'; +import { empty, find, set } from './utils.js'; +import { array, hole } from './handler.js'; +import { parse } from './parser.js'; +import { + Hole, + render as _render, + html, svg, + htmlFor, svgFor, + attr +} from './keyed.js'; + +const parseHTML = parse(false, true); +const parseSVG = parse(true, true); + +const parent = () => ({ childNodes: [] }); + +const skip = (node, data) => { + +}; + +const reMap = (parentNode, { childNodes }) => { + for (let first = true, { length } = childNodes; length--;) { + let node = childNodes[length]; + switch (node.nodeType) { + case COMMENT_NODE: + if (node.data === '') { + let nested = 0; + while (node = node.previousSibling) { + length--; + if (node.nodeType === COMMENT_NODE) { + if (node.data === '') nested++; + else if (node.data === '<>') { + if (!nested--) break; + } + } + else + parentNode.childNodes.unshift(node); + } + } + else if (/\[(\d+)\]/.test(node.data)) { + let many = +RegExp.$1; + parentNode.childNodes.unshift(node); + while (many--) { + node = node.previousSibling; + if (node.nodeType === COMMENT_NODE && node.data === '}') { + node = skip(node, '{'); + } + } + } + break; + case TEXT_NODE: + // ignore browser artifacts on closing fragments + if (first && !node.data.trim()) break; + default: + parentNode.childNodes.unshift(node); + break; + } + first = false; + } + return parentNode; +}; + +const hydrate = (root, {s, t, v}) => { + debugger; + const { b: entries, c: direct } = (s ? parseSVG : parseHTML)(t, v); + const { length } = entries; + // let's assume hydro is used on purpose with valid templates + // to use entries meaningfully re-map the container. + // This is complicated yet possible. + // * fragments are allowed only top-level + // * nested fragments will likely be wrapped in holes + // * arrays can point at either fragments, DOM nodes, or holes + // * arrays can't be path-addressed if not for the comment itself + // * ideally their previous content should be pre-populated with nodes, holes and fragments + // * it is possible that the whole dance is inside-out so that nested normalized content + // can be then addressed (as already live) by the outer content + const fake = reMap(parent(), root, direct); + const details = length ? [] : empty; + for (let current, prev, i = 0; i < length; i++) { + const { a: path, b: update, c: name } = entries[i]; + // adjust the length of the first path node + if (!direct) path[path.length - 1]++; + // TODO: node should be adjusted if it's array or hole + // * if it's array, no way caching it as current helps + // * if it's a hole or attribute/text thing, current helps + let node = path === prev ? current : (current = find(root, (prev = path))); + if (!direct) path[path.length - 1]--; + details[i] = detail( + update, + node, + name, + // TODO: find and resolve the array via the next `` + // TODO: resolve the cache via the surrounding hole + update === array ? [] : (update === hole ? cache() : null) + ); + } + return abc(t, root, details); +}; + +const known = new WeakMap; + +const render = (where, what) => { + const hole = typeof what === 'function' ? what() : what; + if (hole instanceof Hole) { + const info = known.get(where) || set(known, where, hydrate(where, hole)); + if (info.a === hole.t) { + hole.toDOM(info); + return where; + } + } + return _render(where, hole); +}; + +const { document } = globalThis; + +export { Hole, document, render, html, svg, htmlFor, svgFor, attr }; diff --git a/esm/parser.js b/esm/parser.js index 1ff4f74..6ceb9bf 100644 --- a/esm/parser.js +++ b/esm/parser.js @@ -33,31 +33,46 @@ const createPath = node => { const textNode = () => document.createTextNode(''); +const prefix = 'isµ'; + /** * @param {TemplateStringsArray} template * @param {boolean} xml * @returns {Resolved} */ -const resolve = (template, values, xml) => { - const content = createContent(parser(template, prefix, xml), xml); +const resolve = (template, values, xml, holed) => { + let entries = empty, markup = parser(template, prefix, xml); + if (holed) markup = markup.replace( + new RegExp(``, 'g'), + '$&' + ); + const content = createContent(markup, xml); const { length } = template; - let entries = empty; if (length > 1) { const replace = []; const tw = document.createTreeWalker(content, 1 | 128); let i = 0, search = `${prefix}${i++}`; entries = []; while (i < length) { - const node = tw.nextNode(); + let node = tw.nextNode(); // these are holes or arrays if (node.nodeType === COMMENT_NODE) { if (node.data === search) { // ⚠️ once array, always array! const update = isArray(values[i - 1]) ? array : hole; if (update === hole) replace.push(node); + else if (holed) { + // ⚠️ this operation works only with uhtml/dom + // it would bail out native TreeWalker + const { previousSibling, nextSibling } = node; + previousSibling.data = '[]'; + nextSibling.remove(); + } entries.push(abc(createPath(node), update, null)); search = `${prefix}${i++}`; } + // ⚠️ this operation works only with uhtml/dom + else if (holed && node.data === '#') node.remove(); } else { let path; @@ -107,15 +122,19 @@ const resolve = (template, values, xml) => { len = 0; } - return set(cache, template, abc(content, entries, len === 1)); + return abc(content, entries, len === 1); }; -/** @type {WeakMap} */ -const cache = new WeakMap; -const prefix = 'isµ'; - /** * @param {boolean} xml + * @param {boolean} holed * @returns {(template: TemplateStringsArray, values: any[]) => Resolved} */ -export default xml => (template, values) => cache.get(template) || resolve(template, values, xml); +export const parse = (xml, holed) => { + /** @type {WeakMap} */ + const cache = new WeakMap; + return (template, values) => ( + cache.get(template) || + set(cache, template, resolve(template, values, xml, holed)) + ); +}; diff --git a/esm/rabbit.js b/esm/rabbit.js index 686afdb..4cae51d 100644 --- a/esm/rabbit.js +++ b/esm/rabbit.js @@ -1,10 +1,10 @@ import { array, hole } from './handler.js'; import { cache } from './literals.js'; +import { parse } from './parser.js'; import create from './creator.js'; -import parser from './parser.js'; -const createHTML = create(parser(false)); -const createSVG = create(parser(true)); +const createHTML = create(parse(false, false)); +const createSVG = create(parse(true, false)); /** * @param {import("./literals.js").Cache} info diff --git a/package-lock.json b/package-lock.json index 54a2a27..5df5924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "uhtml", - "version": "4.5.0", + "version": "4.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "uhtml", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { "@preact/signals-core": "1.6.0", diff --git a/package.json b/package.json index 1751b97..f567ddd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uhtml", - "version": "4.5.0", + "version": "4.6.0", "description": "A micro HTML/SVG render", "main": "./cjs/index.js", "scripts": { diff --git a/rollup/es.config.js b/rollup/es.config.js index fce0e38..1522029 100644 --- a/rollup/es.config.js +++ b/rollup/es.config.js @@ -28,6 +28,14 @@ export default [ name: 'uhtml', }, }, + { + plugins, + input: './esm/hydro.js', + output: { + esModule: true, + file: './hydro.js', + }, + }, { plugins, input: './esm/index.js', diff --git a/rollup/ssr.cjs b/rollup/ssr.cjs index 5795b7b..35b1b3a 100644 --- a/rollup/ssr.cjs +++ b/rollup/ssr.cjs @@ -8,6 +8,7 @@ const uhtml = readFileSync(init).toString(); const content = [ 'const document = content ? new DOMParser().parseFromString(content, ...rest) : new Document;', 'const { constructor: DocumentFragment } = document.createDocumentFragment();', + 'document[__chunks__] = true;', ]; writeFileSync(init + '_', ` @@ -15,6 +16,7 @@ writeFileSync(init + '_', ` import Document from './dom/document.js'; import DOMParser from './dom/dom-parser.js'; +import { __chunks__ } from './dom/symbols.js'; import { value } from './dom/symbols.js'; import Comment from './dom/comment.js'; diff --git a/test/hydro.html b/test/hydro.html new file mode 100644 index 0000000..faf5cbc --- /dev/null +++ b/test/hydro.html @@ -0,0 +1,50 @@ + + + + Hello Hydro + +

Hello Hydro

+
+
+ + +
+ + + diff --git a/test/hydro.mjs b/test/hydro.mjs new file mode 100644 index 0000000..4a5dcce --- /dev/null +++ b/test/hydro.mjs @@ -0,0 +1,59 @@ +import init from '../esm/init-ssr.js'; + +function App(state) { + return html` +

${state.title}

+
+
    ${[]}
+ + +
+ `; +} + +const component = (target, Callback) => { + const effect = { + target, + update(...args) { + render(target, Callback.apply(effect, args)); + } + }; + return Callback.bind(effect); +}; + +const state = { title: 'Hello Hydro', count: 0 }; + +const { document, render, html } = init(` + + + + + + ${state.title} + + + +`); + +const { body } = document; + +const Body = component(body, App); + +render(body, Body(state)); + +console.log(document.toString()); diff --git a/test/parser.mjs b/test/parser.mjs new file mode 100644 index 0000000..0a36fab --- /dev/null +++ b/test/parser.mjs @@ -0,0 +1,11 @@ +import parser from '@webreflection/uparser'; + +const prefix = 'isµ'; +const re = new RegExp(``, 'g'); + +const template = t => t; + +console.log( + parser(template`a${1}b`, prefix, false) + .replace(re, '$&') +); diff --git a/test/virtual.mjs b/test/virtual.mjs new file mode 100644 index 0000000..1c048fb --- /dev/null +++ b/test/virtual.mjs @@ -0,0 +1,118 @@ +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const COMMENT_NODE = 8; + +const mapped = new WeakMap; + +const asArray = data => /^\[(\d+)\]$/.test(data) ? +RegExp.$1 : -1; + +const faker = ({ tagName }) => ({ tagName, childNodes: [] }); + +const skipArray = (node, many) => { + return many; +}; + +// in a hole there could be: +// * a fragment +// * a hole +// * an element +// * a dom node +const skipHole = (fake, node) => { + let first = true, level = 0; + fake.unshift(node); + while ((node = node.previousSibling)) { + if (node.nodeType === COMMENT_NODE) { + const { data } = node; + if (data === '{') { + if (!level--) { + fake.unshift(node); + return; + } + } + else if (data === '}') { + if (!level++ && first) { + // hole in a hole + } + } + else if (first && data === '') { + // fragment in hole + } + } + // element or text in hole + else if (first) fake.unshift(node); + first = false; + } +}; + +const virtual = (parent, asFragment) => { + let ref = mapped.get(parent); + if (!ref) { + mapped.set(parent, (ref = faker(parent))); + const { childNodes: fake } = ref; + const { childNodes: live } = parent; + for (let { length } = live; length--;) { + let node = live[length]; + switch (node.nodeType) { + case ELEMENT_NODE: + fake.unshift(virtual(node, false)); + break; + case COMMENT_NODE: { + const { data } = node; + if (data === '}') { + skipHole(fake, node); + length -= 2; + } + else if (data === '') { + + } + else { + fake.unshift(node); + const many = asArray(data); + if (-1 < many) + length -= skipArray(node, many); + } + break; + } + case TEXT_NODE: + if (asFragment && !node.data.trim()) break; + fake.unshift(node); + } + asFragment = false; + } + } + return ref; +}; + + +import init from '../esm/init-ssr.js'; + +const { document, render, html } = init(); + +const reveal = ({ tagName, childNodes }, level = 0) => { + const out = []; + out.push('\n', ' '.repeat(level), `<${tagName}>`); + for (let i = 0; i < childNodes.length; i++) { + const node = childNodes[i]; + switch (node.nodeType) { + case COMMENT_NODE: + if (!i) out.push('\n', ' '.repeat(level + 1)); + out.push(``); + break; + case TEXT_NODE: + if (!i) out.push('\n', ' '.repeat(level + 1)); + out.push(node.data); + break; + default: + out.push(reveal(node, level + 1)); + break; + } + } + out.push('\n', ' '.repeat(level), ``); + return out.join(''); +}; + +render(document.body, html`
a${[html`b`]}c${[html`d`, html`e`]}f
`); +console.log(document.body.toString()); + +console.debug(virtual(document.body, false).childNodes[0]); +console.log(reveal(virtual(document.body, false)));