Skip to content

Hydro #113

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open

Hydro #113

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion esm/dom/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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] = [
Expand Down
1 change: 1 addition & 0 deletions esm/dom/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
8 changes: 6 additions & 2 deletions esm/dom/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
);
}
}
118 changes: 118 additions & 0 deletions esm/hydro.js
Original file line number Diff line number Diff line change
@@ -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 `<!--[i]-->`
// 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 };
39 changes: 29 additions & 10 deletions esm/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<!--${prefix}\\d+-->`, '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;
Expand Down Expand Up @@ -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<TemplateStringsArray, Resolved>} */
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<TemplateStringsArray, Resolved>} */
const cache = new WeakMap;
return (template, values) => (
cache.get(template) ||
set(cache, template, resolve(template, values, xml, holed))
);
};
6 changes: 3 additions & 3 deletions esm/rabbit.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions rollup/es.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export default [
name: 'uhtml',
},
},
{
plugins,
input: './esm/hydro.js',
output: {
esModule: true,
file: './hydro.js',
},
},
{
plugins,
input: './esm/index.js',
Expand Down
2 changes: 2 additions & 0 deletions rollup/ssr.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ 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 + '_', `
// ⚠️ WARNING - THIS FILE IS AN ARTIFACT - DO NOT EDIT

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';
Expand Down
50 changes: 50 additions & 0 deletions test/hydro.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html><html><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Hello Hydro</title>
<script type="module">
await new Promise($ => setTimeout($, 2000));
import { render, html } from '../hydro.js';
function App(state) {
return html`
<h1>${state.title}</h1>
<div>
<ul>${[]}</ul>
<input autofocus>
<button onclick=${() => {
state.count++;
this.update(state);
}}>
Clicked ${state.count} times
</button>
</div>
`;
}
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 { body } = document;
const Body = component(body, App);
render(body, Body(state));
</script>
</head><body><!--<>--><h1><!--{-->Hello Hydro<!--}--></h1>
<div>
<ul><!--[]--><!--[0]--></ul>
<input autofocus>
<button>
Clicked <!--{-->0<!--}--> times
</button>
</div><!--</>--></body>
<!--#-->
</html>

Loading
Loading