Compare commits
10 commits
c175a610da
...
465cc74796
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465cc74796 | ||
|
|
dc947d16b9 | ||
|
|
324f4abd4b | ||
|
|
cda04f48ed | ||
|
|
c8e50cb629 | ||
|
|
e9f235505f | ||
|
|
ffba038b35 | ||
|
|
cbbb87cd2e | ||
|
|
5299bd34ee | ||
|
|
48ac1249c8 |
14 changed files with 1745 additions and 157 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
.venv
|
||||
.DS_Store
|
||||
dbs
|
||||
.env
|
||||
|
|
|
|||
57
bliss-cli/live-editor/artifact-editor.eta
Normal file
57
bliss-cli/live-editor/artifact-editor.eta
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<article data-bliss-artifact-editor
|
||||
data-kind="<%= it.kind %>" data-id="<%= it.artifact.id %>"
|
||||
data-structure-id="<%= it.artifact.structure_id %>" data-method="<%= it.artifact.verb || '' %>"
|
||||
class="border-2 border-black bg-white text-black shadow-[4px_4px_0_#000]">
|
||||
<header class="flex items-center gap-2 border-b-4 border-double border-black p-3">
|
||||
<strong class="min-w-0 flex-1 truncate"><% if (it.kind === 'route') { %><%= it.artifact.verb %> <%= it.artifact.path %><% } else { %><%= it.artifact.name %><% } %></strong>
|
||||
<span class="text-xs uppercase"><%= it.kind %></span><span data-status class="text-xs"></span>
|
||||
</header>
|
||||
<div class="p-3">
|
||||
<% if (it.kind === 'route' && it.artifact.verb !== 'GET') { %>
|
||||
<p class="mb-2 border border-amber-700 bg-amber-100 p-2 text-sm">Saving does not replay this <%= it.artifact.verb %> route.</p>
|
||||
<% } %>
|
||||
<label class="block text-xs font-bold uppercase tracking-widest"><%= it.kind === 'route' ? 'Handler' : 'Template' %></label>
|
||||
<div data-editor="<%= it.kind === 'route' ? 'handler' : 'content' %>" data-mode="<%= it.kind === 'route' ? 'javascript' : 'html' %>" class="h-[min(55vh,34rem)] w-full border-2 border-black"><%= it.kind === 'route' ? it.artifact.handler : it.artifact.content %></div>
|
||||
<% if (it.kind === 'template') { %>
|
||||
<details class="mt-3" open>
|
||||
<summary class="cursor-pointer text-xs font-bold uppercase tracking-widest">Rendering test</summary>
|
||||
<div data-editor="test_object" data-mode="javascript" class="mt-1 h-32 w-full border-2 border-black"><%= it.artifact.test_object || '' %></div>
|
||||
<iframe data-template-test title="Template rendering test" src="/workshop/<%= it.artifact.structure_id %>/template/<%= it.artifact.id %>/preview" class="mt-2 h-64 w-full border-2 border-black"></iframe>
|
||||
</details>
|
||||
<% } %>
|
||||
<button data-save type="button" class="mt-3 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000]">Save</button>
|
||||
</div>
|
||||
</article>
|
||||
<script>
|
||||
(function () {
|
||||
const card = document.currentScript.previousElementSibling;
|
||||
if (!card || card.dataset.ready) return;
|
||||
card.dataset.ready = "true";
|
||||
const editors = Object.fromEntries([...card.querySelectorAll("[data-editor]")].map((node) => {
|
||||
const editor = ace.edit(node);
|
||||
editor.setTheme("ace/theme/monokai");
|
||||
editor.session.setMode(`ace/mode/${node.dataset.mode}`);
|
||||
return [node.dataset.editor, editor];
|
||||
}));
|
||||
card.querySelector("[data-save]").onclick = async () => {
|
||||
const status = card.querySelector("[data-status]");
|
||||
const { kind, id, structureId } = card.dataset;
|
||||
status.textContent = "Saving…";
|
||||
const body = kind === "route"
|
||||
? { handler: editors.handler.getValue() }
|
||||
: { content: editors.content.getValue(), test_object: editors.test_object.getValue() };
|
||||
const response = await fetch(`/workshop/${structureId}/${kind}/${id}`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) return void (status.textContent = "Save failed");
|
||||
status.textContent = "Saved";
|
||||
if (kind === "template") {
|
||||
const frame = card.querySelector("[data-template-test]");
|
||||
frame.src = `${frame.src.split("?")[0]}?t=${Date.now()}`;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent("bliss:source-saved", {
|
||||
detail: { kind, id, method: kind === "route" ? card.dataset.verb : undefined },
|
||||
}));
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
3
bliss-cli/live-editor/inspector.js
Normal file
3
bliss-cli/live-editor/inspector.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
function handler(req, res) {
|
||||
res.render("inspector/slideout", {});
|
||||
}
|
||||
80
bliss-cli/live-editor/install.js
Normal file
80
bliss-cli/live-editor/install.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Install/update the live editor using Bliss's public CLI workflow. This file
|
||||
// intentionally does not open the Bliss database directly.
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
const repo = path.resolve(__dirname, "../..");
|
||||
const cli = path.join(repo, "bliss-cli/bliss");
|
||||
|
||||
function bliss(...args) {
|
||||
const result = spawnSync(process.execPath, [cli, ...args], {
|
||||
cwd: repo,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0) throw new Error(result.stderr || result.stdout);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function json(...args) {
|
||||
return JSON.parse(bliss(...args));
|
||||
}
|
||||
|
||||
const structures = json("structures");
|
||||
let structure = structures.find((item) => item.name === "__bliss_live_editor");
|
||||
if (!structure) {
|
||||
const created = json("create-structure", "__bliss_live_editor");
|
||||
structure = { id: created.id };
|
||||
}
|
||||
|
||||
const structureId = String(structure.id);
|
||||
bliss("update-settings", structureId, "--route-prefix", "/_bliss");
|
||||
let state = json("structure", structureId);
|
||||
if (!state.dbs.some((db) => String(db.id) === "0" && db.alias === "bliss")) {
|
||||
bliss("attach-db", structureId, "0", "bliss");
|
||||
}
|
||||
|
||||
const templates = [
|
||||
["inspector/slideout", "slideout.eta"],
|
||||
["inspector/route_editor", "route-editor.eta"],
|
||||
["inspector/template_editor", "template-editor.eta"],
|
||||
["inspector/artifact_editor", "artifact-editor.eta"],
|
||||
];
|
||||
for (const [name, filename] of templates) {
|
||||
let template = state.templates.find((item) => item.name === name);
|
||||
if (!template) {
|
||||
template = json("create-template", structureId, name);
|
||||
}
|
||||
bliss(
|
||||
"update-template",
|
||||
structureId,
|
||||
String(template.id),
|
||||
"--content-file",
|
||||
path.join(__dirname, filename),
|
||||
);
|
||||
}
|
||||
|
||||
const routes = [
|
||||
["/inspector", "inspector.js"],
|
||||
["/editor/route/:id", "route-editor.js"],
|
||||
["/editor/template/:id", "template-editor.js"],
|
||||
];
|
||||
for (const [routePath, filename] of routes) {
|
||||
let route = state.routes.find(
|
||||
(item) => item.verb === "GET" && item.path === routePath,
|
||||
);
|
||||
if (!route) {
|
||||
route = json("create-route", structureId, "GET", routePath);
|
||||
}
|
||||
bliss(
|
||||
"update-route",
|
||||
structureId,
|
||||
String(route.id),
|
||||
"--handler-file",
|
||||
path.join(__dirname, filename),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`live editor installed as Structure ${structureId}`);
|
||||
28
bliss-cli/live-editor/route-editor.eta
Normal file
28
bliss-cli/live-editor/route-editor.eta
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<div data-bliss-ui>
|
||||
<div class="mb-2 flex items-center justify-between border-b-4 border-double border-black pb-2"><strong><%= it.sourceRoute.verb %> <%= it.sourceRoute.path %></strong><span data-status class="text-xs"></span></div>
|
||||
<% if (it.sourceRoute.verb !== "GET") { %>
|
||||
<div class="mb-2 rounded border border-amber-600 bg-amber-950 p-2 text-amber-200">Rerunning this <%= it.sourceRoute.verb %> request can repeat writes or external side effects. Bliss will not rerun it automatically.</div>
|
||||
<% } %>
|
||||
<div data-source class="h-[55vh] w-full border-2 border-black"><%= it.sourceRoute.handler %></div>
|
||||
<button data-save type="button" class="mt-2 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">Save Route</button>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const box = document.currentScript.previousElementSibling;
|
||||
if (!box) return;
|
||||
const editor = ace.edit(box.querySelector("[data-source]"));
|
||||
editor.setTheme("ace/theme/monokai");
|
||||
editor.session.setMode("ace/mode/javascript");
|
||||
box.querySelector("[data-save]").onclick = async () => {
|
||||
const status = box.querySelector("[data-status]");
|
||||
status.textContent = "Saving…";
|
||||
const response = await fetch("/workshop/<%= it.sourceRoute.structure_id %>/route/<%= it.sourceRoute.id %>", {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ handler: editor.getValue() })
|
||||
});
|
||||
if (!response.ok) return void (status.textContent = "Save failed");
|
||||
status.textContent = "Saved";
|
||||
document.dispatchEvent(new CustomEvent("bliss:source-saved", { detail: { kind: "route", id: "<%= it.sourceRoute.id %>", method: "<%= it.sourceRoute.verb %>" } }));
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
7
bliss-cli/live-editor/route-editor.js
Normal file
7
bliss-cli/live-editor/route-editor.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
function handler(req, res) {
|
||||
const sql = require("db")("bliss").sql;
|
||||
const kind = "route";
|
||||
const artifact = sql.prepare("SELECT * FROM routes WHERE id = ?").get(req.params.id);
|
||||
if (!artifact) return res.status(404).send("Route not found");
|
||||
res.render("inspector/artifact_editor", { kind, artifact });
|
||||
}
|
||||
200
bliss-cli/live-editor/slideout.eta
Normal file
200
bliss-cli/live-editor/slideout.eta
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<aside id="bliss-live-editor" data-bliss-ui
|
||||
class="fixed inset-y-0 left-0 z-[2147483646] w-[min(28rem,90vw)] overflow-auto border-r-4 border-black bg-white text-black shadow-[8px_0_0_#000]"
|
||||
style="font-family:ui-sans-serif,system-ui">
|
||||
<header class="sticky top-0 flex items-center justify-between border-b-4 border-double border-black bg-white p-3">
|
||||
<div><div class="text-lg font-black tracking-tight">Live Page</div><div class="text-xs">Objects currently on this card</div></div>
|
||||
<button type="button" data-bliss-close class="h-7 w-7 border-2 border-black bg-white text-lg font-black leading-none shadow-[2px_2px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">×</button>
|
||||
</header>
|
||||
<div class="grid min-h-[calc(100vh-61px)] grid-rows-[auto_1fr]">
|
||||
<nav data-bliss-tree class="border-b-4 border-double border-black p-2"></nav>
|
||||
<section data-bliss-editor class="min-h-0 p-3 text-sm">Select a route or template to edit it, or use the ✏️ 📄 📋 👯 buttons on the page.</section>
|
||||
</div>
|
||||
</aside>
|
||||
<script>
|
||||
(function () {
|
||||
const root = document.getElementById("bliss-live-editor");
|
||||
if (!root) return;
|
||||
// Re-running this script (the slideout is remounted on every open) must not
|
||||
// pile up document listeners or leave a stale overlay behind.
|
||||
if (window.__blissLive) window.__blissLive();
|
||||
const cleanups = [];
|
||||
const on = (target, type, handler, opts) => {
|
||||
target.addEventListener(type, handler, opts);
|
||||
cleanups.push(() => target.removeEventListener(type, handler, opts));
|
||||
};
|
||||
window.__blissLive = () => { for (const c of cleanups.splice(0)) c(); window.__blissLive = null; };
|
||||
|
||||
const tree = root.querySelector("[data-bliss-tree]");
|
||||
const editor = root.querySelector("[data-bliss-editor]");
|
||||
root.querySelector("[data-bliss-close]").onclick = () =>
|
||||
document.dispatchEvent(new CustomEvent("bliss:inspector-close"));
|
||||
|
||||
const highlight = (kind, id, method, state) =>
|
||||
document.dispatchEvent(new CustomEvent("bliss:highlight", { detail: { kind, id, method, on: state } }));
|
||||
|
||||
function openEditor(kind, id) {
|
||||
editor.textContent = "Loading…";
|
||||
htmx.ajax("GET", `/_bliss/editor/${kind}/${id}`, { target: editor, swap: "innerHTML" });
|
||||
}
|
||||
|
||||
// Clone runs through the workshop's own modal, dropped into a full-screen host
|
||||
// we manage so it stacks above the slideout and closes on backdrop click.
|
||||
function openModal(url) {
|
||||
const host = document.createElement("div");
|
||||
host.dataset.blissUi = "";
|
||||
host.style.cssText = "position:fixed;inset:0;z-index:2147483646;background:rgba(0,0,0,.45)";
|
||||
host.onclick = (event) => { if (event.target === host) host.remove(); };
|
||||
on(document, "bliss:inspector-close", () => host.remove());
|
||||
document.body.append(host);
|
||||
htmx.ajax("GET", url, { target: host, swap: "innerHTML" });
|
||||
}
|
||||
|
||||
// T R E E -- the list of everything the current page rendered.
|
||||
function sourceButton(label, kind, id, count) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "block w-full border border-transparent px-2 py-1 text-left hover:border-black hover:bg-black hover:text-white";
|
||||
button.textContent = label + (count > 1 ? ` (${count})` : "");
|
||||
button.onmouseenter = () => highlight(kind, id, null, true);
|
||||
button.onmouseleave = () => highlight(kind, id, null, false);
|
||||
if (kind !== "structure") button.onclick = () => openEditor(kind, id);
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderTree(items) {
|
||||
tree.innerHTML = "";
|
||||
const structures = new Map();
|
||||
for (const item of items) {
|
||||
if (!structures.has(item.structureId)) structures.set(item.structureId, { name: item.structureName, routes: new Map(), templates: new Map(), count: 0 });
|
||||
const structure = structures.get(item.structureId);
|
||||
structure.count++;
|
||||
if (!structure.routes.has(item.routeId)) structure.routes.set(item.routeId, { name: item.routeName, count: 0 });
|
||||
structure.routes.get(item.routeId).count++;
|
||||
if (item.templateId) {
|
||||
if (!structure.templates.has(item.templateId)) structure.templates.set(item.templateId, { name: item.templateName, count: 0 });
|
||||
structure.templates.get(item.templateId).count++;
|
||||
}
|
||||
}
|
||||
for (const [structureId, structure] of structures) {
|
||||
const group = document.createElement("details");
|
||||
group.open = true;
|
||||
const heading = document.createElement("summary");
|
||||
heading.className = "cursor-pointer list-none font-semibold";
|
||||
heading.append(sourceButton(structure.name, "structure", structureId, structure.count));
|
||||
group.append(heading);
|
||||
for (const [label, kind, entries] of [["Routes", "route", structure.routes], ["Templates", "template", structure.templates]]) {
|
||||
if (!entries.size) continue;
|
||||
const section = document.createElement("details");
|
||||
section.open = true;
|
||||
section.className = "ml-3";
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "cursor-pointer py-1 text-xs font-bold uppercase tracking-widest";
|
||||
summary.textContent = label;
|
||||
section.append(summary);
|
||||
for (const [id, entry] of entries) section.append(sourceButton(entry.name, kind, id, entry.count));
|
||||
group.append(section);
|
||||
}
|
||||
tree.append(group);
|
||||
}
|
||||
}
|
||||
|
||||
// O V E R L A Y -- little edit/copy/clone toolbars drawn on the page itself,
|
||||
// one per rendered element, anchored to its top-left corner.
|
||||
const layer = document.createElement("div");
|
||||
layer.dataset.blissUi = "";
|
||||
layer.style.cssText = "position:fixed;inset:0;z-index:2147483640;pointer-events:none";
|
||||
root.parentNode.append(layer); // lives inside the inspector root, so it is torn down on close
|
||||
const bars = new Map();
|
||||
|
||||
function sourceEls() {
|
||||
return [...document.querySelectorAll("[data-bliss-structure-id],[data-bliss-template-id]")]
|
||||
.filter((element) => !element.closest("[data-bliss-ui]"));
|
||||
}
|
||||
|
||||
function templatesOf(element) {
|
||||
try { const list = JSON.parse(element.dataset.blissTemplates || "[]"); if (list.length) return list; } catch (_) {}
|
||||
return element.dataset.blissTemplateId
|
||||
? [{ id: element.dataset.blissTemplateId, name: element.dataset.blissTemplateName }]
|
||||
: [];
|
||||
}
|
||||
|
||||
function iconButton(glyph, title, kind, id, method, action) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = glyph;
|
||||
button.title = title;
|
||||
button.style.cssText = "pointer-events:auto;border:2px solid #000;background:#fff;border-radius:6px;min-width:20px;height:20px;padding:0 2px;font-size:11px;line-height:16px;cursor:pointer;box-shadow:1px 1px 0 #000";
|
||||
if (kind) {
|
||||
button.onmouseenter = () => highlight(kind, id, method, true);
|
||||
button.onmouseleave = () => highlight(kind, id, method, false);
|
||||
}
|
||||
button.onclick = (event) => { event.preventDefault(); event.stopPropagation(); action(button); };
|
||||
return button;
|
||||
}
|
||||
|
||||
function buildBar(element) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.dataset.blissUi = "";
|
||||
wrap.style.cssText = "position:absolute;display:flex;gap:3px;pointer-events:none";
|
||||
if (element.dataset.blissRouteId) {
|
||||
wrap.append(iconButton("✏️", "Edit route " + (element.dataset.blissRouteName || ""),
|
||||
"route", element.dataset.blissRouteId, element.dataset.blissMethod,
|
||||
() => openEditor("route", element.dataset.blissRouteId)));
|
||||
}
|
||||
for (const template of templatesOf(element)) {
|
||||
wrap.append(iconButton("📄", "Edit template " + (template.name || ""),
|
||||
"template", template.id, null, () => openEditor("template", template.id)));
|
||||
}
|
||||
if (element.dataset.blissCopy) {
|
||||
wrap.append(iconButton("📋", "Copy embed snippet", null, null, null, async (button) => {
|
||||
try { await navigator.clipboard.writeText(element.dataset.blissCopy); button.textContent = "✅"; }
|
||||
catch (_) { button.textContent = "❌"; }
|
||||
setTimeout(() => (button.textContent = "📋"), 1000);
|
||||
}));
|
||||
}
|
||||
if (element.dataset.blissClone) {
|
||||
wrap.append(iconButton("👯", "Clone structure", null, null, null,
|
||||
() => openModal(element.dataset.blissClone)));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function place(element, wrap) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (!rect.width && !rect.height) { wrap.style.display = "none"; return; }
|
||||
wrap.style.display = "flex";
|
||||
// Keep the toolbar clear of the slideout: the page root (<body>) is not
|
||||
// itself shifted, so its top-left would otherwise hide behind the panel.
|
||||
const guard = root.getBoundingClientRect().right + 2;
|
||||
const top = rect.top - 24;
|
||||
wrap.style.left = Math.max(guard, rect.left + 2) + "px";
|
||||
wrap.style.top = (top < 2 ? rect.top + 2 : top) + "px";
|
||||
}
|
||||
|
||||
function syncBars() {
|
||||
const live = new Set();
|
||||
for (const element of sourceEls()) {
|
||||
live.add(element);
|
||||
let wrap = bars.get(element);
|
||||
if (!wrap || !wrap.isConnected) { wrap = buildBar(element); bars.set(element, wrap); layer.append(wrap); }
|
||||
place(element, wrap);
|
||||
}
|
||||
for (const [element, wrap] of bars) {
|
||||
if (!live.has(element)) { wrap.remove(); bars.delete(element); }
|
||||
}
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
function reposition() {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => { for (const [element, wrap] of bars) place(element, wrap); });
|
||||
}
|
||||
|
||||
on(window, "scroll", reposition, true);
|
||||
on(window, "resize", reposition);
|
||||
on(document, "bliss:inventory", (event) => { renderTree(event.detail); syncBars(); });
|
||||
on(document, "bliss:inspector-close", () => window.__blissLive && window.__blissLive());
|
||||
|
||||
document.dispatchEvent(new CustomEvent("bliss:inventory-request"));
|
||||
})();
|
||||
</script>
|
||||
25
bliss-cli/live-editor/template-editor.eta
Normal file
25
bliss-cli/live-editor/template-editor.eta
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<div data-bliss-ui>
|
||||
<div class="mb-2 flex items-center justify-between border-b-4 border-double border-black pb-2"><strong><%= it.sourceTemplate.name %></strong><span data-status class="text-xs"></span></div>
|
||||
<div data-source class="h-[55vh] w-full border-2 border-black"><%= it.sourceTemplate.content %></div>
|
||||
<button data-save type="button" class="mt-2 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">Save Template</button>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const box = document.currentScript.previousElementSibling;
|
||||
if (!box) return;
|
||||
const editor = ace.edit(box.querySelector("[data-source]"));
|
||||
editor.setTheme("ace/theme/monokai");
|
||||
editor.session.setMode("ace/mode/html");
|
||||
box.querySelector("[data-save]").onclick = async () => {
|
||||
const status = box.querySelector("[data-status]");
|
||||
status.textContent = "Saving…";
|
||||
const response = await fetch("/workshop/<%= it.sourceTemplate.structure_id %>/template/<%= it.sourceTemplate.id %>", {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: editor.getValue() })
|
||||
});
|
||||
if (!response.ok) return void (status.textContent = "Save failed");
|
||||
status.textContent = "Saved";
|
||||
document.dispatchEvent(new CustomEvent("bliss:source-saved", { detail: { kind: "template", id: "<%= it.sourceTemplate.id %>" } }));
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
7
bliss-cli/live-editor/template-editor.js
Normal file
7
bliss-cli/live-editor/template-editor.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
function handler(req, res) {
|
||||
const sql = require("db")("bliss").sql;
|
||||
const kind = "template";
|
||||
const artifact = sql.prepare("SELECT * FROM templates WHERE id = ?").get(req.params.id);
|
||||
if (!artifact) return res.status(404).send("Template not found");
|
||||
res.render("inspector/artifact_editor", { kind, artifact });
|
||||
}
|
||||
558
chat-rearchitecture.md
Normal file
558
chat-rearchitecture.md
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
# Chat Rearchitecture — "AIM 2005" edition
|
||||
|
||||
> Living design doc **and** build log. Section 1–8 is the plan; Section 10 is the
|
||||
> running notes I update as I build. Keep it honest: record what actually
|
||||
> happened, not what was supposed to.
|
||||
|
||||
---
|
||||
|
||||
## 0. Prime directive (read this first)
|
||||
|
||||
**We are building fun interfaces at the expense of security and safety.** This is
|
||||
a hypermedia playground, not a hardened product. Concretely:
|
||||
|
||||
- **Messages may contain arbitrary HTML/JS and we render it unescaped, forever.**
|
||||
`<%~ it.content %>` (Eta raw) stays. A message can `<script>`, restyle the
|
||||
page, animate, embed another Bliss route, whatever. This is a *feature* and the
|
||||
single most important invariant to preserve. Do not "fix" it. Do not sanitize.
|
||||
- We optimize for looseness, composability, and delight over correctness
|
||||
guarantees. When a choice trades safety for a more slippery/joyful interface,
|
||||
take the fun one and leave a note.
|
||||
|
||||
Everything below serves that directive.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals (what the user asked for)
|
||||
|
||||
1. **Look like AOL Instant Messenger, ~2005.** Really copy it. The current UI is
|
||||
a generic 98.css window with lime-green message area and huge text — wrong.
|
||||
Blue-vs-red color-coded screen names, timestamps, silver beveled chrome,
|
||||
Times New Roman, smiley toolbar. **Text is currently way too big — shrink it.**
|
||||
2. **Reliable, quiet presence.** Login/logout ("signed in / signed out") notices
|
||||
are unreliable and noisy today. Make them consistent and calm.
|
||||
3. **Remember who you are.** Today it forgets your identity constantly. Should
|
||||
persist across reloads via **localStorage**, and **default to the logged-in
|
||||
Bliss user's username** when there's no saved handle.
|
||||
4. **Web-push notifications that actually work.** Today they're half-broken: push
|
||||
is tied to the browser *installation*, but identity churns, so **you get push
|
||||
notifications for your own messages.** Fix the self-notify bug and make
|
||||
delivery consistent.
|
||||
5. **Clean rearchitecture** with **stricter template separation** — pieces that
|
||||
are well-isolated and independently **embeddable**.
|
||||
6. **Event-driven composition like the `sheepgpt` demo.** Components listen for a
|
||||
**message-send** event and emit a **message-received** event, scoped to their
|
||||
own little **uuid/zone** (optional query param on the whole-page GET,
|
||||
propagated down to sub-components).
|
||||
7. **Use individual pieces in isolation:**
|
||||
- a **live chat view** with *no* compose form,
|
||||
- a **compose form** with *no* live chat,
|
||||
- a **frozen** chat (a fixed time range / snapshot),
|
||||
- a **single message** — a bona fide Bliss route you `hx-get`.
|
||||
8. When a new message arrives, the **WebSocket should just tell clients to
|
||||
`hx-get` that message's route** and render it — the socket is a notification
|
||||
bus, not a renderer. (First render may `hx-get` everything; that's fine.)
|
||||
9. Overall feel: **loose, slippy, beautiful, simple, composed of small parts, and
|
||||
perfect.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Current chat (`structure 18`, prefix `/chat`) — inventory & diagnosis
|
||||
|
||||
Read from the live brb.city instance via the `bliss` CLI. Snapshot of what
|
||||
exists and *why it misbehaves*.
|
||||
|
||||
### Routes
|
||||
| id | verb | path | role |
|
||||
|----|------|------|------|
|
||||
| 25 | GET | `/` | full page (`chat` template) |
|
||||
| 22 | GET | `/room` | (legacy poll) |
|
||||
| 34 | WS | `/room` | the socket: message / join / catchup, push fan-out |
|
||||
| 21 | GET | `/message/:id` | render one message (`getMessageById` is **broken**, see below) |
|
||||
| 23 | POST | `/message` | *mislabeled* — actually saves a push subscription |
|
||||
| 45 | POST | `/push-subscribe` | saves push subscription onto `alias_id` |
|
||||
| 24 | GET | `/send-box` | a compose box fragment |
|
||||
| 47 | GET | `/messages` | list fragment |
|
||||
| 50 | POST | `/username` | rename alias |
|
||||
| 51 | GET | `/edit` | settings window (rename + enable notifs) |
|
||||
| 48 | GET | `/manifest.json` | PWA manifest |
|
||||
| 58 | GET | `/service-worker.js` | push SW |
|
||||
| 27/28/33 | WS/GET | `/testing`,`/wstest`,`/sdfsdf` | dead scaffolding |
|
||||
|
||||
### Templates
|
||||
`chat` (page), `room`, `message`, `message-raw`, `messages`, `send-box`, `input`,
|
||||
`join input`, `sign in message`, `success`, `edit-username`, `catch up messages`,
|
||||
`secret recent message form`, `secret auto login form` (**empty!**), `say bridge`.
|
||||
|
||||
### DB (`chat`, db id 2)
|
||||
`aliases(alias_id, user_id, username, created_at, updated_at, push_subscription)`
|
||||
and `messages(message_id, content, alias_id, created_at, updated_at)`.
|
||||
Library helpers: `createAlias`, `getOrCreateAlias`, `createMessage`,
|
||||
`getAllMessages(before, after, limit)`, `getMessageById` (**bug**),
|
||||
`updateUsername`, `updateAliasSubscription`, `getAliasSubscription`, etc.
|
||||
|
||||
### Root-cause diagnosis (the bugs, precisely)
|
||||
|
||||
- **Forgets you / new identity every join.** The `join` event calls
|
||||
`createAlias(username)` — a *brand-new row every time*, stored in
|
||||
`req.session.alias_id`. There is no localStorage, and the `secret auto login
|
||||
form` template that was meant to re-join is **literally empty**. So every fresh
|
||||
session (new tab, cleared cookie, expired session) = a new stranger.
|
||||
- **Self-notify push bug.** Push subscription is written onto whatever
|
||||
`alias_id` is in session *at subscribe time*. Later your session's `alias_id`
|
||||
changes (see above), so `sendPushNotifications(msg, senderAliasId)` excludes
|
||||
the *new* alias while your subscription still lives on the *old* alias →
|
||||
**the server pushes your own message back to you.** Push is keyed by ephemeral
|
||||
identity instead of by a stable device/endpoint.
|
||||
- **Noisy/unreliable presence.** `broadcastSigning` fires on every socket
|
||||
`open`/`close`. With per-embed sockets, reconnects, and multiple tabs, you get
|
||||
a storm of "guest signed in / signed out". Presence is tied to raw socket
|
||||
lifecycle, not to a debounced notion of a *person* being present.
|
||||
- **`getMessageById` is broken SQL** (`message.id` should be
|
||||
`messages.message_id`, missing `message_id`/`created_at` selects) — so the
|
||||
"render a single message by route" primitive (goal #8) doesn't actually work.
|
||||
- **Text too big.** `chat` uses `text-xl`; message area is `bg-lime-400`; message
|
||||
bubbles are `bg-black text-white rounded-lg` — a modern chat-bubble look, the
|
||||
opposite of AIM.
|
||||
- **Weak separation.** The page hard-codes `/chat/...` URLs, mixes transport +
|
||||
UI + PWA + push all in one `chat` template, and has no zone/uuid scoping, so
|
||||
you can't drop "just the live view" or "just the compose box" somewhere else
|
||||
cleanly, and two instances on one page would collide on DOM ids
|
||||
(`#chat_room`, `#form`, ...).
|
||||
|
||||
---
|
||||
|
||||
## 3. The AIM 2005 aesthetic (design target)
|
||||
|
||||
Reference: classic AIM IM window. Defining traits (from memory + research):
|
||||
|
||||
- **Silver/gray beveled window chrome**, blue gradient title bar reading
|
||||
something like **"Instant Message"** with a little running-man logo vibe.
|
||||
- **Message transcript area: white background**, small text, **Times New Roman**
|
||||
(or the browser serif). Each line:
|
||||
`**ScreenName** (h:mm:ss AM/PM): message` — the **screen name is bold and
|
||||
color-coded: blue for you, red for the other person(s)**, timestamp in gray.
|
||||
- A **formatting toolbar** strip (A font, size, **B** *I* <u>U</u>, color swatch,
|
||||
smiley face) above the compose box — mostly decorative but should *look* right.
|
||||
Text emoticons (`:-)`, `;-)`, `:-P`) → classic yellow smileys is a nice-to-have.
|
||||
- **Compose area**: a bordered text box + a chunky **Send** button (Enter sends).
|
||||
- Small system lines for presence: `ScreenName signed on.` / `signed off.` in
|
||||
gray italics, not bubbles.
|
||||
- **Small text everywhere** (~12–13px). This directly fixes "text too large".
|
||||
|
||||
Implementation: hand-rolled CSS in the page template's `<head>` (self-contained,
|
||||
no external CDN beyond what Bliss already injects). Keep it a single small
|
||||
stylesheet so embeds inherit it, or scope it so isolated embeds can bring their
|
||||
own. Decide in Section 7.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture — small parts, one event bus, one uuid
|
||||
|
||||
### 4.1 The zone (uuid)
|
||||
|
||||
Every whole-page render accepts an optional `?zone=<uuid>` query param (generate
|
||||
one if absent). The zone is:
|
||||
|
||||
- a **DOM-id namespace** — every element id is suffixed `-<zone>` so two chat
|
||||
instances can share a page without colliding (this is what the current code
|
||||
can't do), and
|
||||
- an **event namespace** — the custom events are `chat:send:<zone>` /
|
||||
`chat:message:<zone>` *or* carry `detail.zone` and listeners filter. (Leaning
|
||||
toward `detail.zone` filtering + a bare `chat:send` / `chat:message` name, so
|
||||
cross-zone bridging is possible on purpose. TBD in build.)
|
||||
|
||||
The page GET propagates `zone` down into every embedded sub-component's `hx-get`
|
||||
URL, exactly like `sheepgpt` propagates `zone` into the sheep embed.
|
||||
|
||||
### 4.2 The event model (like sheepgpt's `chat:say`)
|
||||
|
||||
Two DOM custom events, dispatched on `document.body`, bubbling:
|
||||
|
||||
- **`chat:send`** — "please send this message." `detail: { zone, name?, content }`.
|
||||
The compose form dispatches it; *anything* can dispatch it (that's the
|
||||
composability port — same idea as the existing `say bridge` / sheepfriend
|
||||
`chat:say`). A hidden `ws-send` bridge catches it and pushes it over the socket.
|
||||
→ We keep a `chat:say` alias for backwards-compat with sheepfriend.
|
||||
- **`chat:message`** — "a message was received/rendered." `detail: { zone,
|
||||
message_id, name, content }`. Emitted **by the client** when a new message
|
||||
lands in the transcript (so sibling embeds / bots can react — e.g. sheep hears
|
||||
it). This is the **message-received** event the user asked for.
|
||||
|
||||
So: **listen for send, emit received.** A live view with no compose box still
|
||||
emits `chat:message`. A compose form with no live view still dispatches
|
||||
`chat:send`. They only need to share a `zone` (or a socket) to talk.
|
||||
|
||||
### 4.3 The socket is a notification bus, not a renderer
|
||||
|
||||
New-message flow (goal #8):
|
||||
|
||||
1. Client dispatches `chat:send` → hidden `ws-send` form → socket.
|
||||
2. Server WS handler: dedup, persist via `createMessage`, get `message_id`.
|
||||
3. Server broadcasts to all room clients a tiny fragment that is **just an
|
||||
`hx-get` of `/message/:id`** (out-of-band swap appending into the transcript):
|
||||
```html
|
||||
<div id="transcript-<zone>" hx-swap-oob="beforeend">
|
||||
<div hx-get="/aim/message/ID?zone=ZONE" hx-trigger="load" hx-swap="outerHTML"></div>
|
||||
</div>
|
||||
```
|
||||
The socket never ships message *markup* — only a pointer. The `/message/:id`
|
||||
route is the single source of truth for how a message looks.
|
||||
4. `/message/:id` renders the message (unescaped content!) and, on load, dispatches
|
||||
`chat:message` for that zone.
|
||||
|
||||
First page render is allowed to `hx-get` everything (each message its own lazy
|
||||
`hx-get`, or one `messages` batch — batch on first paint for speed, individual
|
||||
`hx-get` for live arrivals). **This makes "a single message" a real, linkable,
|
||||
independently-editable Bliss route** — the whole point.
|
||||
|
||||
### 4.4 The isolated, embeddable pieces (each a GET route)
|
||||
|
||||
| route | what it renders | form? | live? |
|
||||
|-------|-----------------|-------|-------|
|
||||
| `GET /` | full IM window (chrome + transcript + toolbar + compose) | yes | yes |
|
||||
| `GET /live` | transcript only, ws-connected, **no compose** | no | yes |
|
||||
| `GET /compose` | compose form only (+ `chat:send` bridge), **no transcript** | yes | no |
|
||||
| `GET /frozen?before=&after=` | a static snapshot of a time/id range, **no ws** | no | no |
|
||||
| `GET /message/:id` | exactly one message | no | no |
|
||||
|
||||
All accept `?zone=`. All are droppable into any other template via the standard
|
||||
Bliss embed (`<div hx-get="/aim/live?zone=abc" hx-trigger="load"></div>`), which
|
||||
is what the inspector's 📋 copy button already produces. Compose that owns no
|
||||
transcript still works because `chat:send` rides the shared socket/zone.
|
||||
|
||||
---
|
||||
|
||||
## 5. Identity, "remember me", localStorage
|
||||
|
||||
Identity resolution order when the page loads (highest priority first):
|
||||
|
||||
1. **localStorage** `aim:handle` (what you last called yourself), and
|
||||
`aim:client_id` (a stable per-browser UUID minted once).
|
||||
2. **Logged-in Bliss user's username** (default when no saved handle). Requires
|
||||
exposing the current user to the sandbox — see Section 6.
|
||||
3. `"guest"` fallback.
|
||||
|
||||
Rules:
|
||||
|
||||
- **One stable alias per handle**, via `getOrCreateAlias(username)` (already
|
||||
exists) — never `createAlias` on every join. Renaming updates the row.
|
||||
- The client mints `aim:client_id` (UUID) once and **sends it with every send and
|
||||
with every push-subscribe**. This is the *device/identity* key that push
|
||||
exclusion uses, decoupled from the churny `alias_id`. Fixes self-notify.
|
||||
- Auto-rejoin on load: if `aim:handle` exists, silently register it into the
|
||||
session (no visible "join" form unless the user is truly new *and* not logged
|
||||
in). The empty `secret auto login form` template gets a real implementation.
|
||||
- Changing your name writes `aim:handle` and updates the alias; presence and push
|
||||
keep working because they key on `client_id`, not the name.
|
||||
|
||||
---
|
||||
|
||||
## 6. Push notifications — rearchitecture
|
||||
|
||||
Problems: keyed on ephemeral `alias_id`; self-notify; no dedup by endpoint.
|
||||
|
||||
Design:
|
||||
|
||||
- **Subscriptions keyed by `client_id` (stable) and deduped by push `endpoint`.**
|
||||
New table (or reuse `aliases.push_subscription` but add a `push_subs` table):
|
||||
`push_subs(client_id TEXT, endpoint TEXT PRIMARY KEY, subscription TEXT,
|
||||
handle TEXT, updated_at)`. Endpoint is globally unique per browser+push
|
||||
service, so re-subscribing updates in place instead of duplicating.
|
||||
- **On new message**, fan out to every subscription **whose `client_id != the
|
||||
sender's `client_id`.** The sender's `client_id` travels with the `chat:send`
|
||||
payload. → You never get pushed your own message, even if your display name
|
||||
changed, session reset, whatever.
|
||||
- Keep the service worker's "app is visible → suppress + clear notifications"
|
||||
behavior (it's good), but make the visibility ping reliable.
|
||||
- Payload: `{ title: name, body: content, zone }` so the SW can render
|
||||
`Name: message` (AIM-ish) and optionally focus the right zone on click.
|
||||
- Handle `410 Gone` → delete that endpoint row (self-cleaning).
|
||||
|
||||
**Exposing the logged-in user (needed for §5 default):** the sandbox context
|
||||
(`bootstrapContext` in `index.js`) currently exposes `req`, `res`, `eta`,
|
||||
`vapidPublicKey`, etc. `req.session.userId` is present but the *username* is not
|
||||
resolvable inside a structure (no access to the main `users` table). Plan:
|
||||
inject a read-only `req.currentUser = { id, username }` (looked up via
|
||||
`model.getUser`) in the `app.all("*")` handler and the WS dispatch, so **every**
|
||||
structure can greet the logged-in user. This is the one `index.js` change and it
|
||||
benefits the whole platform (restart required to pick it up). *Decision pending —
|
||||
see Open Questions.*
|
||||
|
||||
---
|
||||
|
||||
## 7. Template separation — beefed up
|
||||
|
||||
Principles:
|
||||
|
||||
- **One concern per template.** No template both connects a socket *and* paints a
|
||||
message *and* registers push. Split: `page-chrome`, `transcript`, `message`,
|
||||
`compose`, `toolbar`, `presence-line`, `ws-bridge`, `push-setup`,
|
||||
`identity-boot` (localStorage/auto-login), `styles`.
|
||||
- **Every rendered id is zone-suffixed.** No bare `#chat_room` / `#form`.
|
||||
- **Templates take explicit args, never reach into globals.** A template that
|
||||
needs the zone gets `it.zone`; one that needs a message gets the message fields.
|
||||
This is what makes them embeddable in isolation.
|
||||
- **URL building goes through `route()`** (already available) so prefix changes
|
||||
don't break embeds; never hard-code `/chat/...`.
|
||||
- **`styles` is one self-contained AIM stylesheet**, included by full-page
|
||||
renders; isolated embeds (`/live`, `/compose`) include a slim shared style
|
||||
partial so they look right on their own too.
|
||||
- Keep raw/unescaped message rendering isolated in exactly one place
|
||||
(`message` template) so the "arbitrary HTML/JS" power is obvious and auditable
|
||||
(auditable for *fun*, not for locking down).
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed new structure — route/template map
|
||||
|
||||
Build as a **new structure** (working name **`aim`**, prefix `/aim`) so the live
|
||||
`/chat` keeps working until this is proven. Clone the `chat` db (or attach it) so
|
||||
history carries over — decide whether to share the same SQLite file or start
|
||||
clean (see Open Questions).
|
||||
|
||||
**Routes**
|
||||
- `GET /` — full IM window. Accepts `?zone=`.
|
||||
- `GET /live` — transcript-only live view (ws, no compose).
|
||||
- `GET /compose` — compose-only (dispatches `chat:send`).
|
||||
- `GET /frozen` — static range snapshot (`?before=&after=` or `?since=`).
|
||||
- `GET /message/:id` — one message (fixes `getMessageById`).
|
||||
- `WS /room` — socket bus: `send`(→persist→broadcast hx-get pointer),
|
||||
`presence`, `catchup`. Dedup named/bot messages.
|
||||
- `POST /push-subscribe` — upsert `push_subs` by endpoint, keyed by `client_id`.
|
||||
- `POST /rename` — set handle (updates alias, echoes to localStorage client-side).
|
||||
- `GET /manifest.json`, `GET /service-worker.js` — PWA/push.
|
||||
|
||||
**Templates**
|
||||
`page`, `styles`, `transcript`, `message`, `compose`, `toolbar`,
|
||||
`presence-line`, `ws-bridge` (catches `chat:send`, ships over socket),
|
||||
`msg-pointer` (the tiny hx-get fragment the socket broadcasts), `identity-boot`
|
||||
(localStorage + auto-login + emits `chat:message` wiring), `push-setup`.
|
||||
|
||||
**DB** — extend `chat` library with: `getMessageById` (fixed), `messagesInRange`,
|
||||
`upsertPushSub(client_id, endpoint, sub, handle)`, `deletePushSub(endpoint)`,
|
||||
`subsExcludingClient(client_id)`. Add `push_subs` table via a migration fn in the
|
||||
library (the existing pattern: idempotent `CREATE TABLE` helpers).
|
||||
|
||||
---
|
||||
|
||||
## 9. Phased implementation plan
|
||||
|
||||
- **Phase 0 — Doc & scaffold.** This file. Create `aim` structure, attach/clone
|
||||
`chat` db. ← *you are here*
|
||||
- **Phase 1 — Data & single message.** Fix `getMessageById`, add range +
|
||||
push-sub helpers + `push_subs` migration. Ship `GET /message/:id` + `message`
|
||||
template with correct AIM line formatting and **unescaped content**. Verify a
|
||||
single message renders in isolation.
|
||||
- **Phase 2 — Live view + socket-as-bus.** `GET /live` + `transcript` +
|
||||
`WS /room` broadcasting only `msg-pointer` hx-gets. Confirm new messages appear
|
||||
by the client hx-getting `/message/:id`. Emit `chat:message` on arrival.
|
||||
- **Phase 3 — Compose + event bus.** `GET /compose` + `ws-bridge` listening for
|
||||
`chat:send` (and legacy `chat:say`). Confirm compose-only and live-only work
|
||||
separately, and together via shared `zone`.
|
||||
- **Phase 4 — Identity.** `identity-boot`: localStorage handle + client_id,
|
||||
auto-rejoin, default to logged-in Bliss user. `POST /rename`. (Depends on the
|
||||
`req.currentUser` decision.)
|
||||
- **Phase 5 — Presence.** Debounced, deduped signed-on/off lines keyed by person,
|
||||
not socket. Quiet and consistent.
|
||||
- **Phase 6 — Push.** Rework subscription storage + self-notify fix + SW payload.
|
||||
Test on two devices / two client_ids.
|
||||
- **Phase 7 — AIM chrome & polish.** Full `styles`, toolbar, smileys, small text.
|
||||
`GET /` assembles all pieces. `frozen` view.
|
||||
- **Phase 8 — Compose demo.** A page that nests `/live` + `/compose` + maybe the
|
||||
sheep, proving the parts compose (an `aimgpt` analog to `sheepgpt`).
|
||||
|
||||
Each phase: build with the `bliss` CLI, then verify against brb.city (or local),
|
||||
then log results in Section 10 before moving on.
|
||||
|
||||
---
|
||||
|
||||
## 10. Build log / running notes
|
||||
|
||||
> Newest entries at the bottom. Record commands, ids, surprises, decisions.
|
||||
|
||||
- **2026-08-06 — Phase 0 kickoff.**
|
||||
- Instance under edit: **brb.city** (live; CLI sticky target). Building a *new*
|
||||
structure rather than mutating `#18` so production `/chat` stays up.
|
||||
- Fully inventoried `chat` #18 (routes/templates/db) and the `sheepgpt` #42 +
|
||||
`sheepfriend` #26 event pattern. Root-caused all four reported bugs
|
||||
(forgets-you, self-notify push, noisy presence, + found broken
|
||||
`getMessageById`). Written up in §2.
|
||||
- No standalone "bliss MCP" server exists in this session; driving Bliss via the
|
||||
`bliss` CLI per project convention.
|
||||
- _Next:_ resolve Open Questions (esp. shared vs. fresh DB, and the
|
||||
`req.currentUser` index.js change), then Phase 1.
|
||||
|
||||
- **2026-08-06 — Decisions locked + scaffold built.**
|
||||
- Decisions: **new `aim` structure**, **fresh DB**, **minimal handcrafted
|
||||
`req.currentUser` (`{id, username}`)** in index.js (see §11).
|
||||
- Created structure **`aim` = id 44**, prefix `/aim`, db **`chat` = id 12**.
|
||||
- **Route id map:** `GET /`=145, `GET /live`=146, `GET /compose`=147,
|
||||
`GET /frozen`=148, `GET /message/:id`=149, `WS /room`=150,
|
||||
`POST /push-subscribe`=151, `POST /rename`=152, `GET /manifest.json`=153,
|
||||
`GET /service-worker.js`=154.
|
||||
- **Template id map:** `styles`=124, `message`=125, `transcript`=126,
|
||||
`compose`=127, `toolbar`=128, `presence-line`=129, `ws-bridge`=130,
|
||||
`msg-pointer`=131, `identity-boot`=132, `push-setup`=133, `page`=134,
|
||||
`frozen`=135.
|
||||
- **DB library written & smoke-tested** (Phase 1 data layer): fresh schema
|
||||
(`aliases`, `messages`, `push_subs`), `getMessageById` fixed, range/catchup
|
||||
helpers, push-sub upsert keyed by endpoint + `subsExcludingClient`. Verified
|
||||
alias stability + verbatim HTML storage via REPL, then cleared test rows.
|
||||
- _Next:_ `styles` + `message` template + `GET /message/:id` (single-message
|
||||
primitive).
|
||||
|
||||
- **2026-08-06 — Phases 1–7 built & backend verified on brb.city.**
|
||||
- **All 16 templates + 10 routes written** (ids in the maps above). Structure
|
||||
`aim` #44 is live at `https://brb.city/aim/`.
|
||||
- **Design pivot (recorded so §4–6 match reality): identity is fully
|
||||
client-side.** localStorage holds `aim:handle` + `aim:client_id` (a stable
|
||||
per-browser UUID). The socket carries them on an `identify` event (presence)
|
||||
and each `send` (authorship + push exclusion). The server *trusts* the
|
||||
client's declared identity — correct for a fun playground, and it kills the
|
||||
"forgets who you are" + session-churn class of bugs outright. No server-side
|
||||
alias/session juggling remains.
|
||||
- **The four reported bugs, resolved:**
|
||||
- *Forgets you* → localStorage handle + one stable alias per handle
|
||||
(`getOrCreateAlias`), auto-seeded from the logged-in Bliss user.
|
||||
- *Self-notify push* → subscriptions keyed by `endpoint`, owned by stable
|
||||
`client_id`; fan-out is `subsExcludingClient(senderClientId)`. Your own
|
||||
device is structurally excluded regardless of name/session changes.
|
||||
- *Noisy presence* → counts kept **per person, not per socket**, with a 4s
|
||||
debounce on sign-off, so tabs/reconnects don't spam. (`arrive`/`depart` in
|
||||
the WS handler.)
|
||||
- *Broken single-message* → `getMessageById` rewritten with the correct join;
|
||||
it's now the backbone of the socket-as-pointer-bus design.
|
||||
- **Socket-as-bus confirmed live** via a Node `ws` client: `identify` →
|
||||
presence-line broadcast; `send` → persisted (`message #4`) + a `msg-pointer`
|
||||
broadcast of exactly
|
||||
`<div hx-get="/aim/message/4?zone=z9" hx-trigger="load" hx-swap="outerHTML">`
|
||||
— no message markup crosses the socket. `GET /aim/message/1` renders the AIM
|
||||
line (red bold name, gray timestamp) with **unescaped** `<b>`/`<marquee>`
|
||||
intact. First-render null-slice bug (Eta ASI: an eval block must not start
|
||||
with `(`) found & fixed in `transcript`.
|
||||
- **`req.currentUser` added** (minimal `{id, username}`) in **local**
|
||||
`index.js` + `getUserById` in `db.js` (syntax-checked). ⚠️ This repo is the
|
||||
*local* copy; brb.city runs from `/home/nodejs/bliss2` on another host — **the
|
||||
index.js/db.js change needs deploy + restart there to take effect.** Until
|
||||
then templates fall back to `"Guest"` (graceful; nothing breaks).
|
||||
- Seeded one welcome message; cleared all test rows.
|
||||
|
||||
### Verified ✅ vs. remaining ⏳
|
||||
- ✅ Fresh schema + library (alias stability, verbatim HTML, range/catchup/push helpers)
|
||||
- ✅ `GET /message/:id` single-message primitive (unescaped content)
|
||||
- ✅ `WS /room`: identify→presence, send→persist→pointer broadcast, catchup
|
||||
- ✅ Full page, `/live`, `/compose`, `/frozen` render without error
|
||||
- ✅ Socket ships pointers only; message route is the single render source
|
||||
- ⏳ **Browser check** (Playwright MCP wasn't available this session): the
|
||||
client paths — compose→`chat:send`→ws-bridge ship, `chat:message` emit on
|
||||
arrival, localStorage default handle, rename, autoscroll, push subscribe —
|
||||
are written but not yet driven in a real browser. Do this next session.
|
||||
- ⏳ **Deploy** local `index.js`/`db.js` to brb.city + restart (pm2 `foo`) to
|
||||
enable the logged-in-user default.
|
||||
- ⏳ **Phase 8**: an `aimgpt`-style demo page nesting `/live` + `/compose` (+ the
|
||||
sheep) to show the parts compose.
|
||||
|
||||
- **2026-08-06 — Browser verification + the send-path bug (found via Playwright).**
|
||||
- Got Playwright MCP working (it wanted chromium build 1237; symlinked the
|
||||
installed `chromium-1234` → `chromium-1237` in `~/.cache/ms-playwright`).
|
||||
- **Sending was broken — real bug, now fixed.** Eta's `<%= %>` HTML-escapes, so
|
||||
`var dflt = <%= JSON.stringify('Guest') %>;` rendered as
|
||||
`var dflt = "Guest";` **inside the `<script>`** → "Unexpected token
|
||||
'&'" → the entire `ws-bridge` script threw → `window.aimId` undefined → no
|
||||
`chat:send` listener → messages dispatched into the void (textarea cleared,
|
||||
nothing shipped). Fix: use `<%~ %>` (raw) for JSON in every script context
|
||||
(`ws-bridge`, `identity-boot`, `push-setup`).
|
||||
- Also fixed: `_hyperscript` has **no ternary** (`?:`) — the screen-name span
|
||||
`set my innerHTML to (window.aimId ? … : …)` threw. Replaced with a plain-JS
|
||||
`.aim-screenname` painter in `identity-boot`.
|
||||
- **Verified in a real browser (0 console errors):** typed a message, pressed
|
||||
Enter → full round trip (compose → `chat:send` → ws-bridge ship → persist →
|
||||
`msg-pointer` broadcast → `hx-get /message/2` → rendered). Presence line
|
||||
"Guest signed on." shows. AIM aesthetic confirmed via screenshot
|
||||
(`aim-working.png`): blue title bar, silver bevel, small serif transcript,
|
||||
color-coded bold names, gray timestamps, toolbar, compose. `<marquee>` runs.
|
||||
- Note: the Bliss inspector 🔍 button floats over the Send button (Enter still
|
||||
sends). Handle still defaults to "Guest" until the `req.currentUser` change
|
||||
is deployed to brb.city.
|
||||
|
||||
- **2026-08-06 — Mobile pass.**
|
||||
- Root cause of "text too small on mobile": **no `<meta viewport>`** — page
|
||||
rendered at desktop width and scaled down. Added a mobile head-injection on
|
||||
structure 44 (viewport with `user-scalable=no` + `interactive-widget=resizes-content`,
|
||||
theme-color, apple PWA metas, `touch-action:manipulation` to kill double-tap
|
||||
zoom, favicon + apple-touch-icon links).
|
||||
- Bumped fonts (16px base / 13px timestamps / 13px titlebar), enlarged compose
|
||||
(3 rows, min-height 84px, 16px to avoid iOS focus-zoom), safe-area insets on
|
||||
titlebar/compose.
|
||||
- **AIM favicon:** wget'd the real AIM running-man logo (user OK'd for the test
|
||||
instance), generated 32/180/192/512 + `.ico` via ImageMagick, uploaded to
|
||||
structure 44 (served `brb.city/44/*`), wired manifest icons + head links.
|
||||
- **Keyboard resize (user chose PURE CSS, zero JS):** layout already shrinks
|
||||
via the flex column (`.aim-transcript { flex:1; min-height:0; overflow }`) +
|
||||
`height:100dvh`; `interactive-widget=resizes-content` makes Android shrink
|
||||
the viewport with no JS. iOS Safari has no CSS-only fix, so it falls back to
|
||||
the browser's shift-up. (Briefly tried a `visualViewport` script — verified
|
||||
it shrank 844→544px — then removed it per the clean-CSS decision.)
|
||||
|
||||
- **2026-08-06 — File upload (frontend compression).**
|
||||
- **Architecture:** compression is 100% frontend (the handler sandbox exposes
|
||||
no fs/sharp/ffmpeg). Images: canvas downscale + JPEG quality loop to <1MB
|
||||
(verified: crushed a 21MB PNG). Videos: **ffmpeg.wasm** transcode to H.264/AAC
|
||||
mp4 targeting a bitrate from clip duration (verified: **4.6MB → 986KB**,
|
||||
playable 640×360). Other types: uploaded as-is → inline `<a>` link.
|
||||
Images/videos embed as `<img>`/`<video>`. Toolbar `attach` control.
|
||||
- **Upload path = disk.** `POST /upload` → `require('files').saveFile` →
|
||||
`public/<sid>/<name>`, served at `/<sid>/<name>`; returns same-origin path.
|
||||
Found + fixed a framework bug: `saveFile` did `path.join(__dirname,"public",
|
||||
structureId)` with a **numeric** id (only worked from the workshop route
|
||||
which passes a string) → `String()` coercion in `index.js` (reproduced
|
||||
before/after). ⚠️ **Needs the index.js deploy to brb.city** for uploads to
|
||||
persist live (same deploy as `req.currentUser`).
|
||||
- **ffmpeg.wasm hosting:** the 5 assets (ffmpeg.js, 814.ffmpeg.js worker,
|
||||
util, core.js, 32MB core.wasm) are uploaded to the structure's files
|
||||
(`/44/*`, same-origin) and lazy-loaded on first video; the service worker
|
||||
caches them so they load once. Key gotcha (cost several iterations): in
|
||||
ffmpeg.js 0.12, passing `classWorkerURL` forces a **module** worker, but the
|
||||
UMD worker chunk is **classic** (`importScripts`) → "failed to import
|
||||
ffmpeg-core.js". Fix: load `ffmpeg.js` same-origin and **do NOT pass
|
||||
classWorkerURL** — webpack auto publicPath resolves its default (classic,
|
||||
same-origin) worker to `/44/814.ffmpeg.js`. core/wasm handed in as
|
||||
`toBlobURL` blobs.
|
||||
- iOS caveat: ffmpeg.wasm is memory-bound in WKWebView; short clips fine, very
|
||||
large/long ones may fail. Documented, acceptable for the playground.
|
||||
|
||||
### How to embed the pieces (the payoff)
|
||||
```html
|
||||
<!-- live transcript, no compose -->
|
||||
<div hx-get="/aim/live?zone=abc" hx-trigger="load"></div>
|
||||
<!-- compose only, no transcript -->
|
||||
<div hx-get="/aim/compose?zone=abc" hx-trigger="load"></div>
|
||||
<!-- a frozen slice of history -->
|
||||
<div hx-get="/aim/frozen?after=0&before=50" hx-trigger="load"></div>
|
||||
<!-- one message -->
|
||||
<div hx-get="/aim/message/1?zone=abc" hx-trigger="load"></div>
|
||||
```
|
||||
Rule: **one zone == one ws-bridge (socket).** Compose raw pieces together only
|
||||
with distinct zones, or just use `GET /aim/` (which includes exactly one).
|
||||
Cross-structure port preserved: dispatch `chat:say`/`chat:send` on `body` and a
|
||||
bridge ships it (sheepfriend/sheepgpt keep working).
|
||||
|
||||
---
|
||||
|
||||
## 11. Decisions (resolved 2026-08-06)
|
||||
|
||||
1. **New structure `aim`** (prefix `/aim`). ✅ Production `/chat` #18 stays live
|
||||
and untouched; switch over later once proven.
|
||||
2. **Fresh DB.** ✅ Start clean — no message/alias history carried over. Schema
|
||||
built from scratch for this design (messages, aliases, `push_subs`).
|
||||
3. **Expose a minimal, handcrafted `req.currentUser` in `index.js`.** ✅ Do **not**
|
||||
pass the raw Express/session user object into the sandbox. Construct a small
|
||||
read-only plain object — `{ id, username }` (add fields only as a concrete
|
||||
need appears) — looked up from the main `users` table via `model.getUser`.
|
||||
Inject it in the `app.all("*")` handler (and WS dispatch). Requires a restart
|
||||
of the live brb.city process (pm2 `foo`).
|
||||
4. **Event scoping:** single event name + `detail.zone` filter (lets cross-zone
|
||||
bridges like the sheep listen broadly). Confirm ergonomics during Phase 3.
|
||||
5. **PWA/manifest/service-worker:** keep existing behavior; only change the push
|
||||
*payload* and *keying* (client_id/endpoint).
|
||||
99
db.js
99
db.js
|
|
@ -2,7 +2,9 @@ const fs = require("fs");
|
|||
const path = require("path");
|
||||
const betterSqlite3 = require("better-sqlite3");
|
||||
const { LRUCache } = require("lru-cache");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { Eta } = require("eta");
|
||||
const cheerio = require("cheerio");
|
||||
|
||||
const db = betterSqlite3("./dbs/0.sqlite");
|
||||
|
||||
|
|
@ -54,6 +56,10 @@ function getUser(username) {
|
|||
return db.prepare("SELECT * from users where username = ?").get(username);
|
||||
}
|
||||
|
||||
function getUserById(id) {
|
||||
return db.prepare("SELECT id, username FROM users WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
// _ _ _______ ______ ___ _ _______ __ __ _______ _______
|
||||
// | | _ | || || _ | | | | || || | | || || |
|
||||
// | || || || _ || | || | |_| || _____|| |_| || _ || _ |
|
||||
|
|
@ -90,6 +96,76 @@ function getAllRoutes() {
|
|||
}
|
||||
|
||||
const templateCache = new LRUCache({ max: 100 });
|
||||
const templateContextCache = new LRUCache({ max: 2000, ttl: 30 * 60 * 1000 });
|
||||
|
||||
// The inspector needs the values that produced each rendered template, not a
|
||||
// fresh request's values. Contexts frequently contain helpers (such as
|
||||
// `route`) or cyclic request objects, neither of which belongs in HTML. Keep
|
||||
// the JSON-shaped portion and omit the rest rather than making a page fail to
|
||||
// render just because it cannot be inspected.
|
||||
function inspectableContext(data) {
|
||||
const seen = new WeakSet();
|
||||
try {
|
||||
return JSON.parse(
|
||||
JSON.stringify(data ?? {}, (_key, value) => {
|
||||
if (typeof value === "function" || typeof value === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) return undefined;
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function annotateTemplateHtml(html, template, data) {
|
||||
const context = inspectableContext(data);
|
||||
const contextId = randomUUID();
|
||||
templateContextCache.set(contextId, { templateId: String(template.id), context });
|
||||
function annotate($, element) {
|
||||
const current = $(element).attr("data-bliss-templates");
|
||||
let templates = [];
|
||||
if (current) {
|
||||
try {
|
||||
templates = JSON.parse(current);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!templates.some((item) => String(item.id) === String(template.id))) {
|
||||
templates.push({ id: String(template.id), name: template.name });
|
||||
}
|
||||
$(element).attr("data-bliss-templates", JSON.stringify(templates));
|
||||
const rawContextIds = $(element).attr("data-bliss-template-context-ids");
|
||||
let contextIds = {};
|
||||
if (rawContextIds) {
|
||||
try {
|
||||
contextIds = JSON.parse(rawContextIds);
|
||||
} catch (_) {}
|
||||
}
|
||||
contextIds[String(template.id)] = contextId;
|
||||
$(element).attr("data-bliss-template-context-ids", JSON.stringify(contextIds));
|
||||
if (!$(element).attr("data-bliss-template-id")) {
|
||||
$(element).attr({
|
||||
"data-bliss-template-id": String(template.id),
|
||||
"data-bliss-template-name": template.name,
|
||||
"data-bliss-template-context-id": contextId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const lower = html.trimStart().toLowerCase();
|
||||
if (lower.startsWith("<html") || lower.startsWith("<!doctype")) {
|
||||
const $ = cheerio.load(html);
|
||||
annotate($, $("body")[0]);
|
||||
return $.html();
|
||||
}
|
||||
const $ = cheerio.load(html, null, false);
|
||||
for (const child of $.root().children()) annotate($, child);
|
||||
return $.html();
|
||||
}
|
||||
|
||||
function getTemplater(structId) {
|
||||
let etaInstance = templateCache.get(structId);
|
||||
|
|
@ -102,6 +178,13 @@ function getTemplater(structId) {
|
|||
etaInstance.readFile = function (templateAlias) {
|
||||
return getTemplateContentByName(structId, templateAlias).content;
|
||||
};
|
||||
const etaRender = etaInstance.render;
|
||||
etaInstance.render = function (templateName, data, meta) {
|
||||
const html = etaRender.call(this, templateName, data, meta);
|
||||
if (typeof templateName !== "string") return html;
|
||||
const template = getTemplateByName(structId, templateName);
|
||||
return template ? annotateTemplateHtml(html, template, data) : html;
|
||||
};
|
||||
templateCache.set(structId, etaInstance);
|
||||
}
|
||||
|
||||
|
|
@ -245,6 +328,7 @@ function updateStruct(struct) {
|
|||
|
||||
function updateTemplate(template) {
|
||||
update("templates", ["content", "name", "test_object"], template);
|
||||
templateCache.delete(template.structure_id);
|
||||
recordVersion("template", template.id, template.structure_id, template);
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +342,12 @@ function getTemplate(templateId) {
|
|||
return db.prepare("SELECT * from templates where id = ?").get(templateId);
|
||||
}
|
||||
|
||||
function getInspectableTemplateContext(contextId, templateId) {
|
||||
const entry = templateContextCache.get(contextId);
|
||||
if (!entry || entry.templateId !== String(templateId)) return null;
|
||||
return entry.context;
|
||||
}
|
||||
|
||||
function getTemplateContentByName(structId, name) {
|
||||
return db
|
||||
.prepare(
|
||||
|
|
@ -266,6 +356,12 @@ function getTemplateContentByName(structId, name) {
|
|||
.get(structId, name);
|
||||
}
|
||||
|
||||
function getTemplateByName(structId, name) {
|
||||
return db
|
||||
.prepare("SELECT * from templates where structure_id = ? AND name = ?")
|
||||
.get(structId, name);
|
||||
}
|
||||
|
||||
function createTemplate(structureId, name, content, testObjectString) {
|
||||
const id = db
|
||||
.prepare(
|
||||
|
|
@ -668,6 +764,7 @@ module.exports = {
|
|||
getDbInstance,
|
||||
getAllRoutes,
|
||||
getUser,
|
||||
getUserById,
|
||||
createUser,
|
||||
getStructures,
|
||||
getStructure,
|
||||
|
|
@ -685,7 +782,9 @@ module.exports = {
|
|||
getTemplater,
|
||||
getTemplates,
|
||||
getTemplate,
|
||||
getInspectableTemplateContext,
|
||||
getTemplateContentByName,
|
||||
getTemplateByName,
|
||||
createTemplate,
|
||||
getDbsForStructure,
|
||||
getDb,
|
||||
|
|
|
|||
258
index.js
258
index.js
|
|
@ -2,7 +2,7 @@ const fs = require("fs");
|
|||
const util = require("util");
|
||||
const vm = require("node:vm");
|
||||
const path = require("path");
|
||||
require('dotenv').config()
|
||||
require("dotenv").config();
|
||||
const express = require("express");
|
||||
const session = require("express-session");
|
||||
const fileUpload = require("express-fileupload");
|
||||
|
|
@ -16,10 +16,10 @@ const app = express();
|
|||
const _expressWs = require("express-ws")(app);
|
||||
const bodyParser = require("body-parser");
|
||||
const model = require("./db");
|
||||
const PORT = 3000;
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
const db = model.db;
|
||||
const wsRouter = express.Router()
|
||||
const wsRouter = express.Router();
|
||||
|
||||
let viewpath = path.join(__dirname, "views");
|
||||
let eta = new Eta({ views: viewpath, cache: false, autoEscape: true });
|
||||
|
|
@ -36,7 +36,12 @@ let routeIndex = Object.freeze({
|
|||
});
|
||||
const wsConnections = new Map();
|
||||
|
||||
const INSPECT_OPTS = { showHidden: false, depth: null, colors: false, compact: false };
|
||||
const INSPECT_OPTS = {
|
||||
showHidden: false,
|
||||
depth: null,
|
||||
colors: false,
|
||||
compact: false,
|
||||
};
|
||||
function inspectArgs(args) {
|
||||
return args.map((v) => util.inspect(v, INSPECT_OPTS)).join(" ");
|
||||
}
|
||||
|
|
@ -67,8 +72,8 @@ app.use(
|
|||
app.use("/", wsRouter);
|
||||
app.use("/plumbing", require("./plumbing"));
|
||||
|
||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
|
||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
||||
|
||||
// Configure web-push with your VAPID details
|
||||
webPush.setVapidDetails(
|
||||
|
|
@ -77,11 +82,46 @@ webPush.setVapidDetails(
|
|||
vapidPrivateKey,
|
||||
);
|
||||
|
||||
async function saveFile(structureId, req, uploadedFile, asset=false) {
|
||||
// KaiOS's push service only understands the legacy draft `aesgcm` content
|
||||
// encoding; everyone else (iOS, Chrome, Firefox, modern Safari) uses the
|
||||
// RFC 8291 standard `aes128gcm`, which is web-push's default. The subscription
|
||||
// endpoint hostname tells us which is which, so we pick per-subscription and
|
||||
// keep the standard as the default for anything we don't recognize.
|
||||
const LEGACY_AESGCM_HOSTS = ["push.kaiostech.com", "kai.jiophone.net"];
|
||||
|
||||
function pushEncodingFor(subscription) {
|
||||
try {
|
||||
const host = new URL(subscription.endpoint).hostname;
|
||||
return LEGACY_AESGCM_HOSTS.some((h) => host === h || host.endsWith("." + h))
|
||||
? "aesgcm"
|
||||
: "aes128gcm";
|
||||
} catch {
|
||||
return "aes128gcm";
|
||||
}
|
||||
}
|
||||
|
||||
// Transparent drop-in for the web-push module handed to structures via
|
||||
// require('push'): identical API (setVapidDetails, generateVAPIDKeys, …) via
|
||||
// the prototype chain, but sendNotification auto-selects the content encoding
|
||||
// from the subscription endpoint unless the caller passed one explicitly.
|
||||
const push = Object.create(webPush);
|
||||
push.sendNotification = function (subscription, payload, options = {}) {
|
||||
return webPush.sendNotification(subscription, payload, {
|
||||
...options,
|
||||
contentEncoding: options.contentEncoding || pushEncodingFor(subscription),
|
||||
});
|
||||
};
|
||||
|
||||
async function saveFile(structureId, req, uploadedFile, asset = false) {
|
||||
const name = uploadedFile.name;
|
||||
const [mime_type, mime_subtype] = uploadedFile.mimetype.split("/");
|
||||
const uploadPath = path.join(__dirname, "public", structureId);
|
||||
const storedPath = path.join(structureId, uploadedFile.name);
|
||||
// structureId may arrive as a number (route.structure_id is an INTEGER) when
|
||||
// called from a sandboxed handler via require('files').saveFile — the workshop
|
||||
// /files route passes a string param, which masked this. path.join demands
|
||||
// strings, so coerce.
|
||||
const sid = String(structureId);
|
||||
const uploadPath = path.join(__dirname, "public", sid);
|
||||
const storedPath = path.join(sid, uploadedFile.name);
|
||||
|
||||
fs.mkdirSync(uploadPath, { recursive: true });
|
||||
|
||||
|
|
@ -98,7 +138,7 @@ async function saveFile(structureId, req, uploadedFile, asset=false) {
|
|||
let file = model.getFile(id);
|
||||
file.url = prefixUrlWithHost(req, file.path);
|
||||
|
||||
return file
|
||||
return file;
|
||||
}
|
||||
|
||||
// A console whose log() mirrors to stdout and to the structure's logs table.
|
||||
|
|
@ -115,7 +155,12 @@ function makeConsole(structureId, routeId) {
|
|||
// returning its exports. Each library runs in its own context so nothing
|
||||
// leaks between dbs.
|
||||
function runLibrary(sql, librarySource, console) {
|
||||
const libContext = vm.createContext({ sql, console, fetch, module: { exports: null } });
|
||||
const libContext = vm.createContext({
|
||||
sql,
|
||||
console,
|
||||
fetch,
|
||||
module: { exports: null },
|
||||
});
|
||||
vm.runInContext(librarySource, libContext);
|
||||
return libContext.module.exports;
|
||||
}
|
||||
|
|
@ -125,16 +170,33 @@ function makeLibs(structureId, console) {
|
|||
const dbs = {};
|
||||
for (let appDb of model.getDbsForStructure(structureId)) {
|
||||
const sql = model.getDbInstance(appDb.id);
|
||||
dbs[appDb.alias] = { library: runLibrary(sql, appDb.library, console), sql };
|
||||
dbs[appDb.alias] = {
|
||||
library: runLibrary(sql, appDb.library, console),
|
||||
sql,
|
||||
};
|
||||
}
|
||||
return {
|
||||
eta: model.getTemplater(structureId),
|
||||
db: (alias) => dbs[alias],
|
||||
push: webPush,
|
||||
push: push,
|
||||
files: { saveFile: (...args) => saveFile(structureId, ...args) },
|
||||
};
|
||||
}
|
||||
|
||||
// A small, safe projection of the logged-in user for user-route sandboxes.
|
||||
// Returns null when nobody is logged in. Never leak the password hash or the
|
||||
// raw session — hand structures exactly what they need to say "hi <name>".
|
||||
function currentUserFor(req) {
|
||||
const id = req && req.session && req.session.userId;
|
||||
if (!id) return null;
|
||||
try {
|
||||
const u = model.getUserById(id);
|
||||
return u ? { id: u.id, username: u.username } : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapContext(structureId, routeId, initContext) {
|
||||
const structure = model.getStructure(structureId);
|
||||
const console = makeConsole(structureId, routeId);
|
||||
|
|
@ -161,7 +223,6 @@ function routeWithPrefix(route) {
|
|||
return withPrefix(route.route_prefix, route.path);
|
||||
}
|
||||
|
||||
|
||||
function compileWebsocketHandler(route) {
|
||||
try {
|
||||
// Keep the existing WS handler context for compatibility. Restricting the
|
||||
|
|
@ -246,6 +307,7 @@ wsRouter.ws("*", (ws, req) => {
|
|||
});
|
||||
|
||||
ws.clients = clients;
|
||||
req.currentUser = currentUserFor(req);
|
||||
ws.render = (template, context = {}) => {
|
||||
const structure = model.getStructure(route.structure_id);
|
||||
context.route = makeRoute(structure);
|
||||
|
|
@ -340,6 +402,7 @@ function headChrome(headInjection) {
|
|||
<script src="/js/hyperscript.js"></script>
|
||||
<script src="/js/tailwind.js"></script>
|
||||
<script src="/js/htmx.js"></script>
|
||||
<script src="/js/ace/ace.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
|
||||
<script src="/js/bliss_inspector.js"></script>
|
||||
<script>
|
||||
|
|
@ -362,7 +425,17 @@ function blissAttrs(source) {
|
|||
const attrs = {
|
||||
"data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`,
|
||||
"data-bliss-clone": `/workshop/${source.structureId}/clone_modal/`,
|
||||
"data-bliss-structure-id": String(source.structureId),
|
||||
"data-bliss-structure-name": source.structureName,
|
||||
"data-bliss-route-id": String(source.routeId),
|
||||
"data-bliss-route-name": `${source.verb} ${source.path}`,
|
||||
"data-bliss-method": source.verb,
|
||||
"data-bliss-request-url": source.requestUrl,
|
||||
};
|
||||
if (source.templateId) {
|
||||
attrs["data-bliss-template-id"] = String(source.templateId);
|
||||
attrs["data-bliss-template-name"] = source.templateName;
|
||||
}
|
||||
if (source.copyUrl) attrs["data-bliss-copy"] = source.copyUrl;
|
||||
return attrs;
|
||||
}
|
||||
|
|
@ -385,15 +458,32 @@ function decoratePage(html, { headInjection, source } = {}) {
|
|||
return $.html();
|
||||
}
|
||||
|
||||
// HTMX partial: stamp provenance on the swapped-in elements (an oob swap loses
|
||||
// its own attrs, so we descend into its children) and append an out-of-band
|
||||
// <head> so head injection still reaches the page.
|
||||
// Carry rendered head content through HTMX with a neutral OOB wrapper. A
|
||||
// literal <head> in an HTMX response is discarded by the browser's fragment
|
||||
// parser, and HTMX does not discover a top-level <style hx-swap-oob>. The div
|
||||
// survives long enough for HTMX to append its children to the host's real
|
||||
// #head; the browser then keeps the valid style/link/meta children there.
|
||||
function headOobSwaps(headHtml, headInjection) {
|
||||
const $ = cheerio.load(
|
||||
`<head>${headHtml || ""}${headInjection || ""}</head>`,
|
||||
);
|
||||
const contents = $("head").html();
|
||||
return contents ? `<div hx-swap-oob="beforeend:#head">${contents}</div>` : "";
|
||||
}
|
||||
|
||||
// HTMX partial: stamp provenance on the swapped-in body elements (an OOB swap
|
||||
// loses its own attrs, so we descend into its children), then carry the child
|
||||
// structure's rendered head and configured head injection into the host page.
|
||||
function decorateFragment(html, { headInjection, source } = {}) {
|
||||
const $ = cheerio.load(html, null, false);
|
||||
// Parse as a document so browser-valid top-level styles and links are
|
||||
// collected into head. `cheerio.load(..., null, false)` leaves those nodes as
|
||||
// fragment siblings, which makes them get swapped into body and not apply.
|
||||
const $ = cheerio.load(html);
|
||||
const body = $("body");
|
||||
const attrs = blissAttrs(source);
|
||||
if (attrs) {
|
||||
const targets = [];
|
||||
for (const child of $.root().children()) {
|
||||
for (const child of body.children()) {
|
||||
if (child.attribs && child.attribs["hx-swap-oob"]) {
|
||||
for (const grandchild of child.children) {
|
||||
if (grandchild.attribs) targets.push(grandchild);
|
||||
|
|
@ -404,10 +494,29 @@ function decorateFragment(html, { headInjection, source } = {}) {
|
|||
}
|
||||
for (const target of targets) Object.assign(target.attribs, attrs);
|
||||
}
|
||||
return (
|
||||
$.html() +
|
||||
`<head id="head" hx-oob-swap="beforeend">${headInjection || ""}</head>`
|
||||
return body.html() + headOobSwaps($("head").html(), headInjection);
|
||||
}
|
||||
|
||||
// `res.render()` commonly returns a complete document even when it is serving
|
||||
// an HTMX request. HTMX swaps that document's body into the current page, so
|
||||
// preserve the template provenance from its body on every swapped root before
|
||||
// reducing it to a fragment. Without this, templates from an hx-get child
|
||||
// appear to belong to the outer page; saving `message-raw` in /sheepgpt then
|
||||
// incorrectly refreshes /sheepgpt instead of the embedded /chat instance.
|
||||
function fragmentFromPage(html) {
|
||||
const $ = cheerio.load(html);
|
||||
const body = $("body");
|
||||
const templateAttrs = Object.fromEntries(
|
||||
Object.entries(body[0]?.attribs || {}).filter(([name]) =>
|
||||
name.startsWith("data-bliss-template"),
|
||||
),
|
||||
);
|
||||
if (Object.keys(templateAttrs).length) {
|
||||
for (const child of body.children().toArray()) {
|
||||
Object.assign(child.attribs, templateAttrs);
|
||||
}
|
||||
}
|
||||
return body.html();
|
||||
}
|
||||
|
||||
// Single entry point for turning rendered HTML into a response body. A full
|
||||
|
|
@ -415,13 +524,13 @@ function decorateFragment(html, { headInjection, source } = {}) {
|
|||
// carrying provenance becomes a decorated fragment; anything else passes through.
|
||||
function decorate(html, { headInjection, source, fragment } = {}) {
|
||||
const lower = html.toLowerCase();
|
||||
const isFullDoc =
|
||||
lower.startsWith("<html>") || lower.startsWith("<!doctype");
|
||||
if (isFullDoc || !fragment) {
|
||||
const isFullDoc = lower.startsWith("<html>") || lower.startsWith("<!doctype");
|
||||
if (!fragment) {
|
||||
return decoratePage(html, { headInjection, source });
|
||||
}
|
||||
if (source) return decorateFragment(html, { headInjection, source });
|
||||
return html;
|
||||
const fragmentHtml = isFullDoc ? fragmentFromPage(html) : html;
|
||||
if (source) return decorateFragment(fragmentHtml, { headInjection, source });
|
||||
return fragmentHtml;
|
||||
}
|
||||
|
||||
// One prefix-bound route() helper per structure, shared by every render site.
|
||||
|
|
@ -433,12 +542,48 @@ function renderTemplate(structureId, templateName, context) {
|
|||
return model.getTemplater(structureId).render(templateName, context);
|
||||
}
|
||||
|
||||
// Re-render one saved template instance with the JSON-safe values captured on
|
||||
// that instance when the page was first produced. This is deliberately a
|
||||
// platform endpoint rather than a user route: editing a template must not
|
||||
// replay a route handler (and its side effects) just to update the markup.
|
||||
app.post("/_bliss/render-template", (req, res) => {
|
||||
const structureId = String(req.body?.structureId ?? "");
|
||||
const templateId = String(req.body?.templateId ?? "");
|
||||
const template = model.getTemplate(templateId);
|
||||
if (!template || String(template.structure_id) !== structureId) {
|
||||
return res.status(404).send("Template not found");
|
||||
}
|
||||
|
||||
const structure = model.getStructure(structureId);
|
||||
const savedContext = model.getInspectableTemplateContext(
|
||||
req.body?.contextId,
|
||||
templateId,
|
||||
);
|
||||
if (!savedContext)
|
||||
return res.status(410).send("Template context expired; refresh the page");
|
||||
const context = { ...savedContext };
|
||||
context.route = makeRoute(structure);
|
||||
return res
|
||||
.type("html")
|
||||
.send(renderTemplate(structureId, template.name, context));
|
||||
});
|
||||
|
||||
// Provenance for a rendered response: GET routes are re-embeddable, so they
|
||||
// carry the copy-embed snippet; other verbs don't.
|
||||
function sourceFor(route, req) {
|
||||
function sourceFor(route, req, templateName = null) {
|
||||
const structure = model.getStructure(route.structure_id);
|
||||
const template = templateName
|
||||
? model.getTemplateByName(route.structure_id, templateName)
|
||||
: null;
|
||||
return {
|
||||
structureId: route.structure_id,
|
||||
structureName: structure.name,
|
||||
routeId: route.id,
|
||||
verb: route.verb,
|
||||
path: route.path,
|
||||
requestUrl: req.originalUrl,
|
||||
templateId: template?.id,
|
||||
templateName: template?.name,
|
||||
copyUrl: route.verb === "GET" ? embedHTML(req.originalUrl) : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -477,7 +622,11 @@ function sidebarStuff(structId) {
|
|||
}
|
||||
|
||||
app.get("/workshop/:structure_id", (req, res) => {
|
||||
return renderWorkshop(res, "workshop/editor", sidebarStuff(req.params.structure_id));
|
||||
return renderWorkshop(
|
||||
res,
|
||||
"workshop/editor",
|
||||
sidebarStuff(req.params.structure_id),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/workshop/:structure_id/clone", (req, res) => {
|
||||
|
|
@ -562,8 +711,11 @@ app.put("/workshop/:structure_id/route/:route_id", (req, res) => {
|
|||
model.updateRoute({ ...route, ...req.body });
|
||||
if (req.body.scaffold_page) {
|
||||
// TODO: make this send over scaffold page id too someday
|
||||
const latestPage = model.getLatestScaffoldPage(req.params.route_id)
|
||||
model.updateScaffoldPage({ ...latestPage, content: req.body.scaffold_page })
|
||||
const latestPage = model.getLatestScaffoldPage(req.params.route_id);
|
||||
model.updateScaffoldPage({
|
||||
...latestPage,
|
||||
content: req.body.scaffold_page,
|
||||
});
|
||||
}
|
||||
buildRoutes();
|
||||
return res.send("good");
|
||||
|
|
@ -672,9 +824,12 @@ app.get("/workshop/:structure_id/template/:template_id/preview", (req, res) => {
|
|||
context.it.route = makeRoute(struct);
|
||||
|
||||
return res.send(
|
||||
decorate(renderTemplate(req.params.structure_id, template.name, context.it), {
|
||||
headInjection: struct.head_injection,
|
||||
}),
|
||||
decorate(
|
||||
renderTemplate(req.params.structure_id, template.name, context.it),
|
||||
{
|
||||
headInjection: struct.head_injection,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -683,16 +838,16 @@ app.get("/workshop/:structure_id/route/:id", (req, res) => {
|
|||
let routePrefix = model.getStructure(req.params.structure_id).route_prefix;
|
||||
let previewUrl = null;
|
||||
|
||||
if (route["verb"] == "GET" ) {
|
||||
if (route["verb"] == "GET") {
|
||||
previewUrl = routePrefix
|
||||
? path.join(routePrefix || "", route.path)
|
||||
: route.path;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
previewUrl = `/workshop/${req.params.structure_id}/route/${req.params.id}/preview`;
|
||||
}
|
||||
|
||||
const template = route["verb"] == "GET" ? "workshop/route" : "workshop/cud_route";
|
||||
const template =
|
||||
route["verb"] == "GET" ? "workshop/route" : "workshop/cud_route";
|
||||
|
||||
return renderWorkshop(res, template, {
|
||||
route: route,
|
||||
|
|
@ -743,15 +898,13 @@ app.post("/workshop/:structure_id/files", async (req, res) => {
|
|||
const targetFile = req.files.file;
|
||||
|
||||
try {
|
||||
const uploadedFile = await saveFile(structure_id, req, targetFile)
|
||||
const uploadedFile = await saveFile(structure_id, req, targetFile);
|
||||
return res.send(eta.render("workshop/file_detail", { file: uploadedFile }));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
res.status(500);
|
||||
return res.send(e);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
res.status(500);
|
||||
return res.send(e);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
function prefixUrlWithHost(req, path) {
|
||||
|
|
@ -846,6 +999,11 @@ app.all("*", async (req, res) => {
|
|||
req.params = routeMatch.params;
|
||||
const route = routeMatch.route;
|
||||
|
||||
// Minimal, handcrafted view of the logged-in Bliss user for user routes.
|
||||
// Deliberately NOT the raw session/user object — just {id, username} —
|
||||
// so structures can greet whoever is logged in without exposing internals.
|
||||
req.currentUser = currentUserFor(req);
|
||||
|
||||
const structure = model.getStructure(route.structure_id);
|
||||
|
||||
try {
|
||||
|
|
@ -862,14 +1020,18 @@ app.all("*", async (req, res) => {
|
|||
res.send(
|
||||
decorate(renderTemplate(route.structure_id, template, context), {
|
||||
headInjection: structure.head_injection,
|
||||
source: sourceFor(route, req),
|
||||
source: sourceFor(route, req, template),
|
||||
fragment: req.headers["hx-request"],
|
||||
}),
|
||||
);
|
||||
};
|
||||
const handlerScript = new vm.Script(route.handler, { filename: `handler_${route.id}.js` });
|
||||
const handlerScript = new vm.Script(route.handler, {
|
||||
filename: `handler_${route.id}.js`,
|
||||
});
|
||||
// Prepare the execution code that invokes `handler(req, res)` and awaits it
|
||||
const executionScript = new vm.Script(`handler(req, res); `, { filename: `execution_${route.id}.js` });
|
||||
const executionScript = new vm.Script(`handler(req, res); `, {
|
||||
filename: `execution_${route.id}.js`,
|
||||
});
|
||||
await handlerScript.runInContext(context);
|
||||
const result = await executionScript.runInContext(context);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,120 +1,392 @@
|
|||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Create the magnifying glass emoji button
|
||||
const magnifyingGlass = document.createElement("div");
|
||||
magnifyingGlass.innerHTML = "🔍";
|
||||
magnifyingGlass.className =
|
||||
"fixed bottom-2 right-2 cursor-pointer text-2xl z-50";
|
||||
document.body.appendChild(magnifyingGlass);
|
||||
(function () {
|
||||
if (window.__blissInspectorLoaded) return;
|
||||
window.__blissInspectorLoaded = true;
|
||||
|
||||
let highlighted = false;
|
||||
const ROUTE_SELECTOR = "[data-bliss-structure-id][data-bliss-route-id]";
|
||||
const TEMPLATE_SELECTOR = "[data-bliss-template-id]";
|
||||
let root = null;
|
||||
let open = false;
|
||||
|
||||
// Function to highlight elements
|
||||
function highlightElements() {
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "inspector-modal";
|
||||
document.body.appendChild(modal);
|
||||
function visibleElements(selector) {
|
||||
return [...document.querySelectorAll(selector)].filter(
|
||||
(element) => !element.closest("[data-bliss-ui]"),
|
||||
);
|
||||
}
|
||||
|
||||
const elements = document.querySelectorAll("[data-bliss-route]");
|
||||
elements.forEach((el) => {
|
||||
if (!el.classList.contains("highlighted")) {
|
||||
el.classList.add(
|
||||
"border-2",
|
||||
"border-yellow-500",
|
||||
"highlighted",
|
||||
"p-2",
|
||||
);
|
||||
let controls = document.createElement("div");
|
||||
controls.className =
|
||||
"flex absolute gap-1 -bottom-3 right-0 inspector-controls";
|
||||
function routeElements() {
|
||||
return visibleElements(ROUTE_SELECTOR);
|
||||
}
|
||||
|
||||
// Create the edit icon
|
||||
const editIcon = document.createElement("a");
|
||||
editIcon.href = el.getAttribute("data-bliss-route");
|
||||
editIcon.innerHTML = "✏️";
|
||||
editIcon.className =
|
||||
"text-lg bg-white rounded-full p-1 shadow edit-icon";
|
||||
controls.appendChild(editIcon);
|
||||
function templateElements() {
|
||||
return visibleElements(TEMPLATE_SELECTOR);
|
||||
}
|
||||
|
||||
// Create the copy icon
|
||||
if (el.getAttribute("data-bliss-copy")) {
|
||||
const copyIcon = document.createElement("button");
|
||||
copyIcon.innerHTML = "📋";
|
||||
copyIcon.className =
|
||||
"text-lg bg-white rounded-full p-1 shadow copy-icon";
|
||||
controls.appendChild(copyIcon);
|
||||
copyIcon.addEventListener("click", () => {
|
||||
navigator.clipboard
|
||||
.writeText(el.getAttribute("data-bliss-copy"))
|
||||
.then(() => {
|
||||
copyIcon.innerHTML = "✅";
|
||||
})
|
||||
.catch((err) => {
|
||||
copyIcon.innerHTML = "❌";
|
||||
});
|
||||
function templatesFor(element) {
|
||||
try {
|
||||
const templates = JSON.parse(element.dataset.blissTemplates || "[]");
|
||||
if (templates.length) return templates;
|
||||
} catch (_) {}
|
||||
return element.dataset.blissTemplateId
|
||||
? [
|
||||
{
|
||||
id: element.dataset.blissTemplateId,
|
||||
name: element.dataset.blissTemplateName,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function contextIdFor(element, templateId) {
|
||||
try {
|
||||
const ids = JSON.parse(element.dataset.blissTemplateContextIds || "{}");
|
||||
if (Object.hasOwn(ids, String(templateId)))
|
||||
return ids[String(templateId)];
|
||||
} catch (_) {}
|
||||
return element.dataset.blissTemplateContextId || null;
|
||||
}
|
||||
|
||||
function inventory() {
|
||||
const items = [];
|
||||
for (const owner of routeElements()) {
|
||||
const templates = templateElements().filter(
|
||||
(element) => element.closest(ROUTE_SELECTOR) === owner,
|
||||
);
|
||||
if (!templates.length) templates.push(null);
|
||||
for (const templateElement of templates) {
|
||||
const renderedTemplates = templateElement
|
||||
? templatesFor(templateElement)
|
||||
: [null];
|
||||
for (const template of renderedTemplates) {
|
||||
items.push({
|
||||
structureId: owner.dataset.blissStructureId,
|
||||
structureName: owner.dataset.blissStructureName,
|
||||
routeId: owner.dataset.blissRouteId,
|
||||
routeName: owner.dataset.blissRouteName,
|
||||
templateId: template?.id || null,
|
||||
templateName: template?.name || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the clone icon
|
||||
const cloneIcon = document.createElement("div");
|
||||
cloneIcon.innerHTML = "👯♀️";
|
||||
cloneIcon.className =
|
||||
"text-lg bg-white rounded-full cursor-pointer p-1 shadow clone-icon";
|
||||
cloneIcon.setAttribute("hx-get", el.getAttribute("data-bliss-clone"));
|
||||
cloneIcon.setAttribute("hx-target", "#inspector-modal");
|
||||
|
||||
controls.setAttribute(
|
||||
"_",
|
||||
`on mouseover set my.style.zIndex to 10000
|
||||
on mouseout set my.style.zIndex to "initial"
|
||||
`,
|
||||
);
|
||||
controls.appendChild(cloneIcon);
|
||||
|
||||
el.appendChild(controls);
|
||||
htmx.process(controls);
|
||||
_hyperscript.processNode(controls);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to remove highlights
|
||||
function removeHighlights() {
|
||||
const elements = document.querySelectorAll(
|
||||
"[data-bliss-route].highlighted",
|
||||
);
|
||||
elements.forEach((el) => {
|
||||
el.classList.remove(
|
||||
"border-2",
|
||||
"border-yellow-500",
|
||||
"relative",
|
||||
"highlighted",
|
||||
"p-2",
|
||||
);
|
||||
el.querySelectorAll(".inspector-controls").forEach((el) => el.remove());
|
||||
});
|
||||
}
|
||||
|
||||
magnifyingGlass.addEventListener("click", function () {
|
||||
if (!highlighted) {
|
||||
highlightElements();
|
||||
magnifyingGlass.innerHTML = "❌";
|
||||
} else {
|
||||
removeHighlights();
|
||||
magnifyingGlass.innerHTML = "🔍";
|
||||
}
|
||||
highlighted = !highlighted;
|
||||
return items;
|
||||
}
|
||||
|
||||
function publishInventory() {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("bliss:inventory", { detail: inventory() }),
|
||||
);
|
||||
}
|
||||
|
||||
function matches(element, { kind, id }) {
|
||||
if (kind === "template") {
|
||||
return templatesFor(element).some(
|
||||
(template) => String(template.id) === String(id),
|
||||
);
|
||||
}
|
||||
const field = {
|
||||
structure: "blissStructureId",
|
||||
route: "blissRouteId",
|
||||
}[kind];
|
||||
return field && element.dataset[field] === String(id);
|
||||
}
|
||||
|
||||
function highlight(detail) {
|
||||
const candidates =
|
||||
detail.kind === "template" ? templateElements() : routeElements();
|
||||
for (const element of candidates.filter((el) => matches(el, detail))) {
|
||||
element.classList.toggle("bliss-source-highlight", detail.on);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeKey(node) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
if (node.id) return `id:${node.id}`;
|
||||
if (node.dataset.messageId) return `message:${node.dataset.messageId}`;
|
||||
if (node.dataset.blissRouteId) {
|
||||
return `route:${node.dataset.blissRouteId}:${node.dataset.blissRequestUrl || ""}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameNodeType(left, right) {
|
||||
return (
|
||||
left.nodeType === right.nodeType &&
|
||||
(left.nodeType !== Node.ELEMENT_NODE || left.tagName === right.tagName)
|
||||
);
|
||||
}
|
||||
|
||||
function syncAttributes(element, next) {
|
||||
for (const attribute of [...element.attributes]) {
|
||||
if (!next.hasAttribute(attribute.name))
|
||||
element.removeAttribute(attribute.name);
|
||||
}
|
||||
for (const attribute of [...next.attributes]) {
|
||||
if (element.getAttribute(attribute.name) !== attribute.value) {
|
||||
element.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live regions can contain data that arrived after the template was first
|
||||
// rendered (a WebSocket chat message is the important case). Those nodes
|
||||
// are not present in the saved render context, so retain them during a
|
||||
// template patch instead of deleting them with the old tree.
|
||||
function keepLiveNode(node) {
|
||||
return (
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.matches("[data-bliss-ui]") ||
|
||||
node.hasAttribute("data-message-id") ||
|
||||
node.hasAttribute("data-bliss-route-id"))
|
||||
);
|
||||
}
|
||||
|
||||
function morphNode(current, next) {
|
||||
if (!sameNodeType(current, next)) {
|
||||
current.replaceWith(next.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) {
|
||||
if (current.nodeValue !== next.nodeValue)
|
||||
current.nodeValue = next.nodeValue;
|
||||
return;
|
||||
}
|
||||
|
||||
syncAttributes(current, next);
|
||||
const currentChildren = [...current.childNodes];
|
||||
const keyed = new Map();
|
||||
for (const child of currentChildren) {
|
||||
const key = nodeKey(child);
|
||||
if (key && !keyed.has(key)) keyed.set(key, child);
|
||||
}
|
||||
|
||||
const used = new Set();
|
||||
let cursor = current.firstChild;
|
||||
for (const nextChild of [...next.childNodes]) {
|
||||
const key = nodeKey(nextChild);
|
||||
let child = key ? keyed.get(key) : null;
|
||||
if (child && used.has(child)) child = null;
|
||||
if (!child) {
|
||||
child = currentChildren.find(
|
||||
(candidate) =>
|
||||
!used.has(candidate) &&
|
||||
!nodeKey(candidate) &&
|
||||
sameNodeType(candidate, nextChild),
|
||||
);
|
||||
}
|
||||
if (!child) child = nextChild.cloneNode(false);
|
||||
if (child !== cursor) current.insertBefore(child, cursor);
|
||||
used.add(child);
|
||||
morphNode(child, nextChild);
|
||||
cursor = child.nextSibling;
|
||||
}
|
||||
|
||||
for (const child of currentChildren) {
|
||||
if (!used.has(child) && child.isConnected && !keepLiveNode(child))
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceBody(html, { morph = false } = {}) {
|
||||
const next = new DOMParser().parseFromString(html, "text/html");
|
||||
if (morph) {
|
||||
morphNode(document.body, next.body);
|
||||
htmx.process(document.body);
|
||||
return;
|
||||
}
|
||||
const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")];
|
||||
for (const attribute of [...document.body.attributes]) {
|
||||
document.body.removeAttribute(attribute.name);
|
||||
}
|
||||
for (const attribute of [...next.body.attributes]) {
|
||||
document.body.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
document.body.innerHTML = next.body.innerHTML;
|
||||
for (const node of ui) document.body.appendChild(node);
|
||||
htmx.process(document.body);
|
||||
}
|
||||
|
||||
async function refreshElement(element) {
|
||||
if (element.dataset.blissMethod !== "GET") return;
|
||||
const url = element.dataset.blissRequestUrl;
|
||||
if (!url) return;
|
||||
|
||||
if (element === document.body) {
|
||||
// A page root must be fetched as a document. Asking for an HTMX
|
||||
// fragment here used to parse an empty <body> and erase the page.
|
||||
const response = await fetch(url);
|
||||
if (!response.ok)
|
||||
throw new Error(`GET ${url} returned ${response.status}`);
|
||||
await replaceBody(await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
await htmx.ajax("GET", url, { target: element, swap: "outerHTML" });
|
||||
}
|
||||
|
||||
function outermost(elements, selector) {
|
||||
const set = new Set(elements);
|
||||
return elements.filter((element) => {
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
if (set.has(parent) && parent.matches(selector)) return false;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshTemplateElement(
|
||||
element,
|
||||
templateId,
|
||||
previousRoots = [],
|
||||
) {
|
||||
const response = await fetch("/_bliss/render-template", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
structureId: element.closest(ROUTE_SELECTOR)?.dataset.blissStructureId,
|
||||
templateId,
|
||||
contextId: contextIdFor(element, templateId),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Template ${templateId} returned ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
// The render-template endpoint re-stamps template provenance but not the
|
||||
// route/structure provenance (that comes from the route pipeline). Carry it
|
||||
// over from the element being replaced so the inspector overlay and tree
|
||||
// stay complete after a live template edit, instead of losing the element's
|
||||
// route/copy/clone identity.
|
||||
const provenance = [...element.attributes]
|
||||
.filter((attribute) => attribute.name.startsWith("data-bliss-"))
|
||||
.map((attribute) => ({ name: attribute.name, value: attribute.value }));
|
||||
const carry = (target) => {
|
||||
for (const { name, value } of provenance) {
|
||||
if (!target.hasAttribute(name)) target.setAttribute(name, value);
|
||||
}
|
||||
};
|
||||
if (element === document.body) {
|
||||
await replaceBody(html, { morph: true });
|
||||
carry(document.body);
|
||||
return;
|
||||
}
|
||||
const fragment = document.createElement("template");
|
||||
fragment.innerHTML = html;
|
||||
// A template may legally have several top-level nodes. They share one
|
||||
// captured context id, so replace that one render as a group instead of
|
||||
// rendering the same template once per root and duplicating its output.
|
||||
for (const root of previousRoots) {
|
||||
if (root !== element && root.isConnected) root.remove();
|
||||
}
|
||||
for (const root of fragment.content.children) carry(root);
|
||||
element.replaceWith(fragment.content);
|
||||
htmx.process(document.body);
|
||||
}
|
||||
|
||||
function templateRefreshGroups(templateId) {
|
||||
const elements = outermost(
|
||||
templateElements().filter((element) =>
|
||||
matches(element, { kind: "template", id: templateId }),
|
||||
),
|
||||
TEMPLATE_SELECTOR,
|
||||
);
|
||||
const groups = new Map();
|
||||
for (const element of elements) {
|
||||
const contextId = contextIdFor(element, templateId);
|
||||
const key = contextId || `element:${Math.random()}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(element);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
async function refreshSources({ kind, id, method }) {
|
||||
if (method && method !== "GET") return;
|
||||
const elements =
|
||||
kind === "template"
|
||||
? templateRefreshGroups(id)
|
||||
: outermost(
|
||||
routeElements().filter((element) => matches(element, { kind, id })),
|
||||
ROUTE_SELECTOR,
|
||||
);
|
||||
const refresh =
|
||||
kind === "template"
|
||||
? (group) => refreshTemplateElement(group[0], id, group)
|
||||
: refreshElement;
|
||||
const settled = await Promise.allSettled(elements.map(refresh));
|
||||
const failure = settled.find((result) => result.status === "rejected");
|
||||
if (failure) console.error("Bliss live refresh failed", failure.reason);
|
||||
publishInventory();
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (open) return;
|
||||
open = true;
|
||||
document.documentElement.classList.add("bliss-inspector-open");
|
||||
root = document.createElement("div");
|
||||
root.id = "bliss-inspector-root";
|
||||
root.dataset.blissUi = "";
|
||||
root.setAttribute("hx-get", "/_bliss/inspector");
|
||||
root.setAttribute("hx-trigger", "load");
|
||||
root.setAttribute("hx-swap", "innerHTML");
|
||||
document.body.appendChild(root);
|
||||
htmx.process(root);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open = false;
|
||||
document.documentElement.classList.remove("bliss-inspector-open");
|
||||
root?.remove();
|
||||
root = null;
|
||||
for (const element of [...routeElements(), ...templateElements()]) {
|
||||
element.classList.remove("bliss-source-highlight");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const style = document.createElement("style");
|
||||
style.dataset.blissUi = "";
|
||||
style.textContent = `
|
||||
html.bliss-inspector-open body > :not([data-bliss-ui]) {
|
||||
transform: translateX(min(28rem, 90vw));
|
||||
max-width: calc(100vw - min(28rem, 90vw));
|
||||
}
|
||||
[data-bliss-structure-id], [data-bliss-template-id] {
|
||||
transition: outline-color 80ms linear, background-color 80ms linear;
|
||||
}
|
||||
html.bliss-inspector-open [data-bliss-structure-id],
|
||||
html.bliss-inspector-open [data-bliss-template-id] {
|
||||
outline: 1px dashed rgba(255, 0, 168, .5);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.bliss-source-highlight {
|
||||
outline: 4px solid #ff00a8 !important;
|
||||
outline-offset: 3px !important;
|
||||
background-color: rgba(255, 0, 168, .08) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.textContent = "🔍";
|
||||
trigger.title = "Open Bliss live editor";
|
||||
trigger.dataset.blissUi = "";
|
||||
trigger.style.cssText =
|
||||
"position:fixed;right:.5rem;bottom:.5rem;z-index:2147483647;border:0;background:white;border-radius:999px;padding:.45rem;font-size:1.25rem;cursor:pointer;box-shadow:0 2px 8px #0004";
|
||||
trigger.onclick = () => (open ? hide() : show());
|
||||
document.body.appendChild(trigger);
|
||||
});
|
||||
|
||||
// Observe the document for changes
|
||||
// const observer = new MutationObserver(function (mutations) {
|
||||
// if (highlighted) {
|
||||
// highlightElements();
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Configure the observer
|
||||
// observer.observe(document.body, {
|
||||
// childList: true,
|
||||
// subtree: true,
|
||||
// });
|
||||
});
|
||||
document.addEventListener("bliss:highlight", (event) =>
|
||||
highlight(event.detail),
|
||||
);
|
||||
document.addEventListener("bliss:source-saved", (event) =>
|
||||
refreshSources(event.detail),
|
||||
);
|
||||
document.addEventListener("bliss:inventory-request", publishInventory);
|
||||
document.addEventListener("bliss:inspector-close", hide);
|
||||
document.addEventListener("htmx:afterSwap", () => {
|
||||
if (open) publishInventory();
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
89
tests/test_inspector_sheepgpt.py
Normal file
89
tests/test_inspector_sheepgpt.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Browser regression test for Bliss template saves inside an hx-get child page.
|
||||
|
||||
Run with:
|
||||
.venv/bin/python tests/test_inspector_sheepgpt.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import Page, Request, sync_playwright
|
||||
|
||||
|
||||
BASE_URL = "https://brb.city"
|
||||
SHEEPGPT_URL = f"{BASE_URL}/sheepgpt/"
|
||||
MESSAGE_RAW_TEMPLATE_ID = "28"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkTrace:
|
||||
requests: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
def record(self, request: Request) -> None:
|
||||
self.requests.append((request.method, urlparse(request.url).path))
|
||||
|
||||
def after(self, start: int) -> list[tuple[str, str]]:
|
||||
return self.requests[start:]
|
||||
|
||||
|
||||
class BlissInspector:
|
||||
"""Small page-object API for the inspector's normal user workflow."""
|
||||
|
||||
def __init__(self, page: Page) -> None:
|
||||
self.page = page
|
||||
|
||||
def open_slideout(self) -> None:
|
||||
self.page.get_by_role("button", name="🔍").click()
|
||||
self.page.locator("#bliss-live-editor").wait_for()
|
||||
|
||||
def open_template(self, name: str, template_id: str) -> None:
|
||||
self.page.get_by_role("button", name=re.compile(rf"^{re.escape(name)}(?: |$)")).click()
|
||||
self.page.locator(
|
||||
f'[data-bliss-artifact-editor][data-kind="template"][data-id="{template_id}"]'
|
||||
).wait_for()
|
||||
|
||||
def save_template(self) -> None:
|
||||
self.page.locator("[data-bliss-artifact-editor] [data-save]").click()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page()
|
||||
trace = NetworkTrace()
|
||||
page.on("request", trace.record)
|
||||
|
||||
page.goto(SHEEPGPT_URL, wait_until="networkidle")
|
||||
inspector = BlissInspector(page)
|
||||
inspector.open_slideout()
|
||||
inspector.open_template("message-raw", MESSAGE_RAW_TEMPLATE_ID)
|
||||
|
||||
chat_before = page.locator("#chat").element_handle()
|
||||
assert chat_before is not None, "the embedded chat did not render"
|
||||
start = len(trace.requests)
|
||||
with page.expect_response(re.compile(r"/_bliss/render-template$")) as rendered:
|
||||
inspector.save_template()
|
||||
render_response = rendered.value
|
||||
print(f"template render response: {render_response.status} {render_response.url}")
|
||||
print(f"template render body: {render_response.request.post_data}")
|
||||
print("requests after message-raw save:")
|
||||
for method, path in trace.after(start):
|
||||
print(f" {method} {path}")
|
||||
assert render_response.status == 200, render_response.text()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
requests = trace.after(start)
|
||||
# Saving a child template must never replay the outer sheepgpt route.
|
||||
assert ("GET", "/sheepgpt/") not in requests, requests
|
||||
# The embedded chat itself must stay mounted; only matching template
|
||||
# instances should be patched.
|
||||
assert page.locator("#chat").element_handle() == chat_before
|
||||
assert page.locator("#bliss-live-editor").is_visible()
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue