353 lines
12 KiB
JavaScript
353 lines
12 KiB
JavaScript
// Minimal server-side DOM for running solid-js/html + solid-js/web inside
|
|
// goja. It implements only the surface Solid's client runtime actually
|
|
// touches (enumerated from wwwroot/vendor/solid-js-web.js and
|
|
// solid-js-html.js): linked-list tree mutation, template.innerHTML/.content,
|
|
// element/text/comment creation, attributes, className/textContent, and
|
|
// no-op event wiring.
|
|
//
|
|
// The tree is the source of truth as a doubly-linked list (firstChild,
|
|
// nextSibling, ...) which is how the real DOM models it and what Solid's
|
|
// clone-walk assumes. childNodes is a derived snapshot array so Solid's
|
|
// `[...el.childNodes]` spreads work.
|
|
//
|
|
// HTML *parsing* (innerHTML setter) is the one genuinely hard operation, so
|
|
// it is delegated to Go via __parseHTML (x/net/html) which returns a JSON
|
|
// tree. Serialization back to a string is straightforward and lives here.
|
|
// -mta
|
|
|
|
(function (global) {
|
|
"use strict";
|
|
|
|
var ELEMENT_NODE = 1, TEXT_NODE = 3, COMMENT_NODE = 8, FRAGMENT_NODE = 11;
|
|
|
|
var VOID = {
|
|
area: 1, base: 1, br: 1, col: 1, embed: 1, hr: 1, img: 1, input: 1,
|
|
keygen: 1, link: 1, meta: 1, param: 1, source: 1, track: 1, wbr: 1,
|
|
};
|
|
|
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
function escapeText(s) {
|
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
function escapeAttr(s) {
|
|
return String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
}
|
|
|
|
// ---- Node ----------------------------------------------------------
|
|
|
|
class Node {
|
|
constructor(type) {
|
|
this.nodeType = type;
|
|
this.parentNode = null;
|
|
this.firstChild = null;
|
|
this.lastChild = null;
|
|
this.previousSibling = null;
|
|
this.nextSibling = null;
|
|
this._$host = null;
|
|
this.host = null;
|
|
}
|
|
|
|
get childNodes() {
|
|
var out = [], n = this.firstChild;
|
|
while (n) { out.push(n); n = n.nextSibling; }
|
|
return out;
|
|
}
|
|
|
|
appendChild(child) {
|
|
detach(child);
|
|
child.parentNode = this;
|
|
child.previousSibling = this.lastChild;
|
|
child.nextSibling = null;
|
|
if (this.lastChild) this.lastChild.nextSibling = child;
|
|
else this.firstChild = child;
|
|
this.lastChild = child;
|
|
return child;
|
|
}
|
|
|
|
insertBefore(child, ref) {
|
|
if (ref == null) return this.appendChild(child);
|
|
if (ref.parentNode !== this) throw new Error("insertBefore: ref not a child");
|
|
detach(child);
|
|
child.parentNode = this;
|
|
child.nextSibling = ref;
|
|
child.previousSibling = ref.previousSibling;
|
|
if (ref.previousSibling) ref.previousSibling.nextSibling = child;
|
|
else this.firstChild = child;
|
|
ref.previousSibling = child;
|
|
return child;
|
|
}
|
|
|
|
removeChild(child) {
|
|
if (child.parentNode !== this) throw new Error("removeChild: not a child");
|
|
detach(child);
|
|
return child;
|
|
}
|
|
|
|
replaceChild(newNode, oldNode) {
|
|
this.insertBefore(newNode, oldNode);
|
|
this.removeChild(oldNode);
|
|
return oldNode;
|
|
}
|
|
|
|
remove() { detach(this); }
|
|
|
|
replaceWith() {
|
|
var args = Array.prototype.slice.call(arguments);
|
|
var parent = this.parentNode, ref = this.nextSibling;
|
|
if (!parent) return;
|
|
detach(this);
|
|
for (var i = 0; i < args.length; i++) {
|
|
var a = args[i];
|
|
if (typeof a === "string") a = new Text(a);
|
|
parent.insertBefore(a, ref);
|
|
}
|
|
}
|
|
|
|
cloneNode(deep) {
|
|
var copy = this._shallowClone();
|
|
if (deep) {
|
|
var n = this.firstChild;
|
|
while (n) { copy.appendChild(n.cloneNode(true)); n = n.nextSibling; }
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
get textContent() {
|
|
if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) return this.data;
|
|
var out = "", n = this.firstChild;
|
|
while (n) {
|
|
if (n.nodeType !== COMMENT_NODE) out += n.textContent;
|
|
n = n.nextSibling;
|
|
}
|
|
return out;
|
|
}
|
|
set textContent(value) {
|
|
if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) { this.data = String(value); return; }
|
|
while (this.firstChild) this.removeChild(this.firstChild);
|
|
if (value !== "" && value != null) this.appendChild(new Text(String(value)));
|
|
}
|
|
|
|
querySelectorAll(sel) { return querySelectorAll(this, sel); }
|
|
querySelector(sel) { var r = querySelectorAll(this, sel); return r.length ? r[0] : null; }
|
|
}
|
|
|
|
function detach(node) {
|
|
var p = node.parentNode;
|
|
if (!p) return;
|
|
if (node.previousSibling) node.previousSibling.nextSibling = node.nextSibling;
|
|
else p.firstChild = node.nextSibling;
|
|
if (node.nextSibling) node.nextSibling.previousSibling = node.previousSibling;
|
|
else p.lastChild = node.previousSibling;
|
|
node.parentNode = null;
|
|
node.previousSibling = null;
|
|
node.nextSibling = null;
|
|
}
|
|
|
|
// ---- Text / Comment ------------------------------------------------
|
|
|
|
class Text extends Node {
|
|
constructor(data) { super(TEXT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#text"; }
|
|
_shallowClone() { return new Text(this.data); }
|
|
}
|
|
|
|
class Comment extends Node {
|
|
constructor(data) { super(COMMENT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#comment"; }
|
|
_shallowClone() { return new Comment(this.data); }
|
|
}
|
|
|
|
// ---- Element -------------------------------------------------------
|
|
|
|
class Element extends Node {
|
|
constructor(tagName, ns) {
|
|
super(ELEMENT_NODE);
|
|
this.tagName = tagName;
|
|
this.localName = String(tagName).toLowerCase();
|
|
this.nodeName = this.localName;
|
|
this.namespaceURI = ns || null;
|
|
this.attributes = {};
|
|
this._style = null;
|
|
this._classList = null;
|
|
if (this.localName === "template") this.content = new Fragment();
|
|
}
|
|
|
|
_shallowClone() {
|
|
var copy = new Element(this.tagName, this.namespaceURI);
|
|
for (var k in this.attributes) copy.attributes[k] = this.attributes[k];
|
|
if (this.content) {
|
|
var n = this.content.firstChild;
|
|
while (n) { copy.content.appendChild(n.cloneNode(true)); n = n.nextSibling; }
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
setAttribute(name, value) { this.attributes[name] = String(value); }
|
|
setAttributeNS(_ns, name, value) { this.attributes[name] = String(value); }
|
|
getAttribute(name) { return name in this.attributes ? this.attributes[name] : null; }
|
|
hasAttribute(name) { return name in this.attributes; }
|
|
removeAttribute(name) { delete this.attributes[name]; }
|
|
removeAttributeNS(_ns, name) { delete this.attributes[name]; }
|
|
|
|
get className() { return this.attributes["class"] || ""; }
|
|
set className(v) { this.attributes["class"] = String(v); }
|
|
|
|
get id() { return this.attributes["id"] || ""; }
|
|
set id(v) { this.attributes["id"] = String(v); }
|
|
|
|
get innerHTML() { return serializeChildren(this); }
|
|
set innerHTML(htmlStr) {
|
|
var target = this.content ? this.content : this;
|
|
while (target.firstChild) target.removeChild(target.firstChild);
|
|
var json = global.__parseHTML(String(htmlStr));
|
|
var nodes = buildNodes(JSON.parse(json));
|
|
for (var i = 0; i < nodes.length; i++) target.appendChild(nodes[i]);
|
|
}
|
|
|
|
get style() {
|
|
if (!this._style) this._style = makeStyle(this);
|
|
return this._style;
|
|
}
|
|
get classList() {
|
|
if (!this._classList) this._classList = makeClassList(this);
|
|
return this._classList;
|
|
}
|
|
|
|
// Event wiring is irrelevant to server output.
|
|
addEventListener() {}
|
|
removeEventListener() {}
|
|
}
|
|
|
|
class Fragment extends Node {
|
|
constructor() { super(FRAGMENT_NODE); this.nodeName = "#document-fragment"; }
|
|
_shallowClone() { return new Fragment(); }
|
|
}
|
|
|
|
// ---- style / classList shims --------------------------------------
|
|
|
|
function makeStyle(el) {
|
|
return {
|
|
setProperty: function (k, v) {
|
|
var cur = parseStyle(el.attributes["style"] || "");
|
|
cur[k] = v;
|
|
el.attributes["style"] = stringifyStyle(cur);
|
|
},
|
|
removeProperty: function (k) {
|
|
var cur = parseStyle(el.attributes["style"] || "");
|
|
delete cur[k];
|
|
el.attributes["style"] = stringifyStyle(cur);
|
|
},
|
|
get cssText() { return el.attributes["style"] || ""; },
|
|
set cssText(v) { el.attributes["style"] = String(v); },
|
|
};
|
|
}
|
|
function parseStyle(s) {
|
|
var out = {};
|
|
s.split(";").forEach(function (decl) {
|
|
var i = decl.indexOf(":");
|
|
if (i > -1) out[decl.slice(0, i).trim()] = decl.slice(i + 1).trim();
|
|
});
|
|
return out;
|
|
}
|
|
function stringifyStyle(o) {
|
|
return Object.keys(o).map(function (k) { return k + ":" + o[k]; }).join(";");
|
|
}
|
|
|
|
function makeClassList(el) {
|
|
function read() { return (el.attributes["class"] || "").split(/\s+/).filter(Boolean); }
|
|
function write(list) { el.attributes["class"] = list.join(" "); }
|
|
return {
|
|
add: function () { var l = read(); for (var i = 0; i < arguments.length; i++) if (l.indexOf(arguments[i]) < 0) l.push(arguments[i]); write(l); },
|
|
remove: function () { var l = read(), a = Array.prototype.slice.call(arguments); write(l.filter(function (c) { return a.indexOf(c) < 0; })); },
|
|
toggle: function (c, force) { var l = read(), has = l.indexOf(c) > -1; if (force === undefined ? has : !force) write(l.filter(function (x) { return x !== c; })); else if (!has) { l.push(c); write(l); } },
|
|
contains: function (c) { return read().indexOf(c) > -1; },
|
|
};
|
|
}
|
|
|
|
// ---- build from parsed JSON ---------------------------------------
|
|
|
|
function buildNodes(arr) {
|
|
var out = [];
|
|
for (var i = 0; i < arr.length; i++) out.push(buildNode(arr[i]));
|
|
return out;
|
|
}
|
|
function buildNode(j) {
|
|
if (j.t === "t") return new Text(j.d);
|
|
if (j.t === "c") return new Comment(j.d);
|
|
var el = new Element(j.n, j.ns === "svg" ? SVG_NS : null);
|
|
if (j.a) for (var k in j.a) el.attributes[k] = j.a[k];
|
|
if (j.c) for (var i = 0; i < j.c.length; i++) el.appendChild(buildNode(j.c[i]));
|
|
return el;
|
|
}
|
|
|
|
// ---- serialization -------------------------------------------------
|
|
|
|
function serializeChildren(node) {
|
|
var out = "", n = node.firstChild;
|
|
while (n) { out += serializeNode(n); n = n.nextSibling; }
|
|
return out;
|
|
}
|
|
function serializeNode(node) {
|
|
if (node.nodeType === TEXT_NODE) return escapeText(node.data);
|
|
// "#" is solid-js/html's template insertion placeholder; any that
|
|
// survive instantiation are framework artifacts, not page content.
|
|
if (node.nodeType === COMMENT_NODE) return node.data === "#" ? "" : "<!--" + node.data + "-->";
|
|
if (node.nodeType === FRAGMENT_NODE) return serializeChildren(node);
|
|
// Output the original-case tag (SVG is case-sensitive: viewBox,
|
|
// linearGradient); use the lowercased localName only for lookups.
|
|
var tag = node.tagName, lname = node.localName;
|
|
var s = "<" + tag;
|
|
for (var k in node.attributes) s += " " + k + '="' + escapeAttr(node.attributes[k]) + '"';
|
|
s += ">";
|
|
if (VOID[lname]) return s;
|
|
if (lname === "template" && node.content) s += serializeChildren(node.content);
|
|
else s += serializeChildren(node);
|
|
return s + "</" + tag + ">";
|
|
}
|
|
|
|
// ---- minimal querySelectorAll (only script,style and *[data-hk]) ---
|
|
|
|
function querySelectorAll(root, sel) {
|
|
var wantHk = /\[data-hk\]/.test(sel);
|
|
var tags = sel.split(",").map(function (s) { return s.trim().replace(/\[.*\]/, "").replace("*", "").toLowerCase(); }).filter(Boolean);
|
|
var out = [];
|
|
(function walk(n) {
|
|
var c = n.firstChild;
|
|
while (c) {
|
|
if (c.nodeType === ELEMENT_NODE) {
|
|
if (wantHk && c.attributes["data-hk"] != null) out.push(c);
|
|
else if (tags.indexOf(c.localName) > -1) out.push(c);
|
|
walk(c);
|
|
}
|
|
c = c.nextSibling;
|
|
}
|
|
})(root.content || root);
|
|
return out;
|
|
}
|
|
|
|
// ---- document ------------------------------------------------------
|
|
|
|
var document = {
|
|
createElement: function (tag) { return new Element(tag, null); },
|
|
createElementNS: function (ns, tag) { return new Element(tag, ns); },
|
|
createTextNode: function (data) { return new Text(data); },
|
|
createComment: function (data) { return new Comment(data); },
|
|
createDocumentFragment: function () { return new Fragment(); },
|
|
importNode: function (node, deep) { return node.cloneNode(deep); },
|
|
addEventListener: function () {},
|
|
removeEventListener: function () {},
|
|
nodeType: 9,
|
|
};
|
|
|
|
global.document = document;
|
|
global.Node = Node;
|
|
global.Element = Element;
|
|
global.Text = Text;
|
|
global.Comment = Comment;
|
|
global.window = global;
|
|
|
|
// Serialize a node's children (innerHTML) — the Go side calls this to
|
|
// extract the rendered markup from the render root.
|
|
global.__serialize = function (node) { return serializeChildren(node); };
|
|
|
|
})(globalThis);
|