Skip to content

Commit e6af534

Browse files
mbostockFil
andauthored
no wrapper span (#1416)
* no wrapper span * fix top-level text * simplify inline syntax error * comment marker * fix table loading * fix iterable of non-node * robust placeholder parser * replacement strategy * fix backslashes * drop invalid interpolations * comments * simplify * remove unused codeErrors * preserve comments when highlighting * handle fenced code blocks * more better parser * clean and comment * cleaner * fix fences within comments * Update test/placeholder-test.ts Co-authored-by: Philippe Rivière <[email protected]> * ignore invalid tokens * root.parentNode for visibility --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent a0965a5 commit e6af534

38 files changed

+1056
-261
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"d3-hierarchy": "^3.1.2",
6969
"esbuild": "^0.20.1",
7070
"fast-array-diff": "^1.1.0",
71+
"fast-deep-equal": "^3.1.3",
7172
"gray-matter": "^4.0.3",
7273
"he": "^1.2.0",
7374
"highlight.js": "^11.8.0",
@@ -118,7 +119,6 @@
118119
"eslint-config-prettier": "^9.1.0",
119120
"eslint-import-resolver-typescript": "^3.6.1",
120121
"eslint-plugin-import": "^2.29.0",
121-
"fast-deep-equal": "^3.1.3",
122122
"glob": "^10.3.10",
123123
"mocha": "^10.2.0",
124124
"prettier": "^3.0.3 <3.1",

src/client/main.js

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,20 @@ export const runtime = new Runtime(library);
2020
export const main = runtime.module();
2121

2222
const cellsById = new Map();
23+
const rootsById = findRoots(document.body);
2324

2425
export function define(cell) {
2526
const {id, inline, inputs = [], outputs = [], body} = cell;
2627
const variables = [];
2728
cellsById.get(id)?.variables.forEach((v) => v.delete());
2829
cellsById.set(id, {cell, variables});
29-
const root = document.querySelector(`#cell-${id}`);
30-
const loading = root.querySelector(".observablehq-loading");
30+
const root = rootsById.get(id);
31+
const loading = findLoading(root);
32+
root._nodes = [];
33+
if (loading) root._nodes.push(loading);
3134
const pending = () => reset(root, loading);
3235
const rejected = (error) => reject(root, error);
33-
const v = main.variable({_node: root, pending, rejected}, {shadow: {}}); // _node for visibility promise
36+
const v = main.variable({_node: root.parentNode, pending, rejected}, {shadow: {}}); // _node for visibility promise
3437
if (inputs.includes("display") || inputs.includes("view")) {
3538
let displayVersion = -1; // the variable._version of currently-displayed values
3639
const display = inline ? displayInline : displayBlock;
@@ -67,31 +70,52 @@ export function define(cell) {
6770
// original loading indicator in this case, which applies to inline expressions
6871
// and expression code blocks.
6972
function reset(root, loading) {
70-
if (root.classList.contains("observablehq--error")) {
71-
root.classList.remove("observablehq--error");
73+
if (root._error) {
74+
root._error = false;
7275
clear(root);
73-
if (loading) root.append(loading);
76+
if (loading) displayNode(root, loading);
7477
}
7578
}
7679

7780
function reject(root, error) {
7881
console.error(error);
79-
root.classList.add("observablehq--error"); // see reset
82+
root._error = true; // see reset
8083
clear(root);
81-
root.append(inspectError(error));
84+
displayNode(root, inspectError(error));
85+
}
86+
87+
function displayNode(root, node) {
88+
if (node.nodeType === 11) {
89+
let child;
90+
while ((child = node.firstChild)) {
91+
root._nodes.push(child);
92+
root.parentNode.insertBefore(child, root);
93+
}
94+
} else {
95+
root._nodes.push(node);
96+
root.parentNode.insertBefore(node, root);
97+
}
8298
}
8399

84100
function clear(root) {
85-
root.textContent = "";
101+
for (const v of root._nodes) v.remove();
102+
root._nodes.length = 0;
86103
}
87104

88105
function displayInline(root, value) {
89-
if (isNode(value) || typeof value === "string" || !value?.[Symbol.iterator]) root.append(value);
90-
else root.append(...value);
106+
if (isNode(value)) {
107+
displayNode(root, value);
108+
} else if (typeof value === "string" || !value?.[Symbol.iterator]) {
109+
displayNode(root, document.createTextNode(value));
110+
} else {
111+
for (const v of value) {
112+
displayNode(root, isNode(v) ? v : document.createTextNode(v));
113+
}
114+
}
91115
}
92116

93117
function displayBlock(root, value) {
94-
root.append(isNode(value) ? value : inspect(value));
118+
displayNode(root, isNode(value) ? value : inspect(value));
95119
}
96120

97121
export function undefine(id) {
@@ -103,3 +127,33 @@ export function undefine(id) {
103127
function isNode(value) {
104128
return value instanceof Node && value instanceof value.constructor;
105129
}
130+
131+
export function findRoots(root) {
132+
const roots = new Map();
133+
const iterator = document.createNodeIterator(root, 128, null);
134+
let node;
135+
while ((node = iterator.nextNode())) {
136+
if (isRoot(node)) {
137+
roots.set(node.data.slice(1, -1), node);
138+
}
139+
}
140+
return roots;
141+
}
142+
143+
function isRoot(node) {
144+
return node.nodeType === 8 && /^:[0-9a-f]{8}(?:-\d+)?:$/.test(node.data);
145+
}
146+
147+
function isLoading(node) {
148+
return node.nodeType === 1 && node.tagName === "O-LOADING";
149+
}
150+
151+
export function findLoading(root) {
152+
const sibling = root.previousSibling;
153+
return sibling && isLoading(sibling) ? sibling : null;
154+
}
155+
156+
export function registerRoot(id, node) {
157+
if (node == null) rootsById.delete(id);
158+
else rootsById.set(id, node);
159+
}

src/client/preview.js

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {FileAttachment, registerFile} from "npm:@observablehq/stdlib";
22
import {main, runtime, undefine} from "./main.js";
3+
import {findLoading, findRoots, registerRoot} from "./main.js";
34
import {enableCopyButtons} from "./pre.js";
45

56
export * from "./index.js";
@@ -48,12 +49,30 @@ export function open({hash, eval: compile} = {}) {
4849
case "add": {
4950
for (const item of items) {
5051
const pos = oldPos + offset;
51-
if (pos < root.children.length) {
52-
root.children[pos].insertAdjacentHTML("beforebegin", item);
52+
if (pos < root.childNodes.length) {
53+
const child = root.childNodes[pos];
54+
if (item.type === 1) {
55+
if (child.nodeType === 1) {
56+
child.insertAdjacentHTML("beforebegin", item.value);
57+
} else {
58+
root.insertAdjacentHTML("beforeend", item.value);
59+
root.insertBefore(root.lastChild, child);
60+
}
61+
} else if (item.type === 3) {
62+
root.insertBefore(document.createTextNode(item.value), child);
63+
} else if (item.type === 8) {
64+
root.insertBefore(document.createComment(item.value), child);
65+
}
5366
} else {
54-
root.insertAdjacentHTML("beforeend", item);
67+
if (item.type === 1) {
68+
root.insertAdjacentHTML("beforeend", item.value);
69+
} else if (item.type === 3) {
70+
root.appendChild(document.createTextNode(item.value));
71+
} else if (item.type === 8) {
72+
root.appendChild(document.createComment(item.value));
73+
}
5574
}
56-
indexCells(addedCells, root.children[pos]);
75+
indexCells(addedCells, root.childNodes[pos]);
5776
++offset;
5877
}
5978
break;
@@ -62,13 +81,13 @@ export function open({hash, eval: compile} = {}) {
6281
let removes = 0;
6382
for (let i = 0; i < items.length; ++i) {
6483
const pos = oldPos + offset;
65-
if (pos < root.children.length) {
66-
const child = root.children[pos];
84+
if (pos < root.childNodes.length) {
85+
const child = root.childNodes[pos];
6786
indexCells(removedCells, child);
6887
child.remove();
6988
++removes;
7089
} else {
71-
console.error(`remove out of range: ${pos}${root.children.length}`);
90+
console.error(`remove out of range: ${pos}${root.childNodes.length}`);
7291
}
7392
}
7493
offset -= removes;
@@ -77,7 +96,16 @@ export function open({hash, eval: compile} = {}) {
7796
}
7897
}
7998
for (const [id, removed] of removedCells) {
80-
addedCells.get(id)?.replaceWith(removed);
99+
if (!addedCells.has(id)) {
100+
registerRoot(id, null);
101+
} else {
102+
replaceRoot(addedCells.get(id), removed);
103+
}
104+
}
105+
for (const [id, root] of addedCells) {
106+
if (!removedCells.has(id)) {
107+
registerRoot(id, root);
108+
}
81109
}
82110
for (const id of message.code.removed) {
83111
undefine(id);
@@ -138,11 +166,8 @@ export function open({hash, eval: compile} = {}) {
138166
};
139167

140168
function indexCells(map, node) {
141-
if (node.id.startsWith("cell-")) {
142-
map.set(node.id, node);
143-
}
144-
for (const cell of node.querySelectorAll("[id^=cell-]")) {
145-
map.set(cell.id, cell);
169+
for (const [id, root] of findRoots(node)) {
170+
map.set(id, root);
146171
}
147172
}
148173

@@ -151,3 +176,11 @@ export function open({hash, eval: compile} = {}) {
151176
socket.send(JSON.stringify(message));
152177
}
153178
}
179+
180+
export function replaceRoot(added, removed) {
181+
findLoading(added)?.remove();
182+
added.replaceWith(removed);
183+
for (const n of removed._nodes) {
184+
removed.parentNode.insertBefore(n, removed);
185+
}
186+
}

src/html.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export function rewriteHtml(
169169
? hljs.highlight(child.textContent!, {language}).value
170170
: isElement(child)
171171
? child.outerHTML
172+
: isComment(child)
173+
? `<!--${he.escape(child.data)}-->`
172174
: "";
173175
}
174176
code.innerHTML = html;
@@ -183,6 +185,29 @@ export function rewriteHtml(
183185
h.append(a);
184186
}
185187

188+
// For incremental update during preview, we need to know the direct children
189+
// of the body statically; therefore we must wrap any top-level cells with a
190+
// span to avoid polluting the direct children with dynamic content.
191+
for (let child = document.body.firstChild; child; child = child.nextSibling) {
192+
if (isRoot(child)) {
193+
const parent = document.createElement("span");
194+
const loading = findLoading(child);
195+
child.replaceWith(parent);
196+
if (loading) parent.appendChild(loading);
197+
parent.appendChild(child);
198+
child = parent;
199+
}
200+
}
201+
202+
// In some contexts, such as a table, the <o-loading> element may be
203+
// reparented; enforce the requirement that the <o-loading> element
204+
// immediately precedes its root by removing any violating elements.
205+
for (const l of document.querySelectorAll("o-loading")) {
206+
if (!l.nextSibling || !isRoot(l.nextSibling)) {
207+
l.remove();
208+
}
209+
}
210+
186211
return document.body.innerHTML;
187212
}
188213

@@ -208,14 +233,31 @@ function resolveSrcset(srcset: string, resolve: (specifier: string) => string):
208233
.join(", ");
209234
}
210235

211-
function isText(node: Node): node is Text {
236+
export function isText(node: Node): node is Text {
212237
return node.nodeType === 3;
213238
}
214239

215-
function isElement(node: Node): node is Element {
240+
export function isComment(node: Node): node is Comment {
241+
return node.nodeType === 8;
242+
}
243+
244+
export function isElement(node: Node): node is Element {
216245
return node.nodeType === 1;
217246
}
218247

248+
function isRoot(node: Node): node is Comment {
249+
return isComment(node) && /^:[0-9a-f]{8}(?:-\d+)?:$/.test(node.data);
250+
}
251+
252+
function isLoading(node: Node): node is Element {
253+
return isElement(node) && node.tagName === "O-LOADING";
254+
}
255+
256+
function findLoading(node: Node): Element | null {
257+
const sibling = node.previousSibling;
258+
return sibling && isLoading(sibling) ? sibling : null;
259+
}
260+
219261
/**
220262
* Denotes a string that contains HTML source; when interpolated into an html
221263
* tagged template literal, it will not be escaped. Use Html.unsafe to denote

0 commit comments

Comments
 (0)