Compare commits
No commits in common. "465cc747961372d7001b14f30cd5e0d5fc681809" and "c175a610da5ec5fda6f27430f834866df994ea2e" have entirely different histories.
465cc74796
...
c175a610da
14 changed files with 150 additions and 1738 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
node_modules
|
node_modules
|
||||||
.venv
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dbs
|
dbs
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
function handler(req, res) {
|
|
||||||
res.render("inspector/slideout", {});
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
#!/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}`);
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,558 +0,0 @@
|
||||||
# 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,9 +2,7 @@ const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const betterSqlite3 = require("better-sqlite3");
|
const betterSqlite3 = require("better-sqlite3");
|
||||||
const { LRUCache } = require("lru-cache");
|
const { LRUCache } = require("lru-cache");
|
||||||
const { randomUUID } = require("node:crypto");
|
|
||||||
const { Eta } = require("eta");
|
const { Eta } = require("eta");
|
||||||
const cheerio = require("cheerio");
|
|
||||||
|
|
||||||
const db = betterSqlite3("./dbs/0.sqlite");
|
const db = betterSqlite3("./dbs/0.sqlite");
|
||||||
|
|
||||||
|
|
@ -56,10 +54,6 @@ function getUser(username) {
|
||||||
return db.prepare("SELECT * from users where username = ?").get(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// _ _ _______ ______ ___ _ _______ __ __ _______ _______
|
// _ _ _______ ______ ___ _ _______ __ __ _______ _______
|
||||||
// | | _ | || || _ | | | | || || | | || || |
|
// | | _ | || || _ | | | | || || | | || || |
|
||||||
// | || || || _ || | || | |_| || _____|| |_| || _ || _ |
|
// | || || || _ || | || | |_| || _____|| |_| || _ || _ |
|
||||||
|
|
@ -96,76 +90,6 @@ function getAllRoutes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateCache = new LRUCache({ max: 100 });
|
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) {
|
function getTemplater(structId) {
|
||||||
let etaInstance = templateCache.get(structId);
|
let etaInstance = templateCache.get(structId);
|
||||||
|
|
@ -178,13 +102,6 @@ function getTemplater(structId) {
|
||||||
etaInstance.readFile = function (templateAlias) {
|
etaInstance.readFile = function (templateAlias) {
|
||||||
return getTemplateContentByName(structId, templateAlias).content;
|
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);
|
templateCache.set(structId, etaInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,7 +245,6 @@ function updateStruct(struct) {
|
||||||
|
|
||||||
function updateTemplate(template) {
|
function updateTemplate(template) {
|
||||||
update("templates", ["content", "name", "test_object"], template);
|
update("templates", ["content", "name", "test_object"], template);
|
||||||
templateCache.delete(template.structure_id);
|
|
||||||
recordVersion("template", template.id, template.structure_id, template);
|
recordVersion("template", template.id, template.structure_id, template);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,12 +258,6 @@ function getTemplate(templateId) {
|
||||||
return db.prepare("SELECT * from templates where id = ?").get(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) {
|
function getTemplateContentByName(structId, name) {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -356,12 +266,6 @@ function getTemplateContentByName(structId, name) {
|
||||||
.get(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) {
|
function createTemplate(structureId, name, content, testObjectString) {
|
||||||
const id = db
|
const id = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -764,7 +668,6 @@ module.exports = {
|
||||||
getDbInstance,
|
getDbInstance,
|
||||||
getAllRoutes,
|
getAllRoutes,
|
||||||
getUser,
|
getUser,
|
||||||
getUserById,
|
|
||||||
createUser,
|
createUser,
|
||||||
getStructures,
|
getStructures,
|
||||||
getStructure,
|
getStructure,
|
||||||
|
|
@ -782,9 +685,7 @@ module.exports = {
|
||||||
getTemplater,
|
getTemplater,
|
||||||
getTemplates,
|
getTemplates,
|
||||||
getTemplate,
|
getTemplate,
|
||||||
getInspectableTemplateContext,
|
|
||||||
getTemplateContentByName,
|
getTemplateContentByName,
|
||||||
getTemplateByName,
|
|
||||||
createTemplate,
|
createTemplate,
|
||||||
getDbsForStructure,
|
getDbsForStructure,
|
||||||
getDb,
|
getDb,
|
||||||
|
|
|
||||||
258
index.js
258
index.js
|
|
@ -2,7 +2,7 @@ const fs = require("fs");
|
||||||
const util = require("util");
|
const util = require("util");
|
||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
require("dotenv").config();
|
require('dotenv').config()
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const session = require("express-session");
|
const session = require("express-session");
|
||||||
const fileUpload = require("express-fileupload");
|
const fileUpload = require("express-fileupload");
|
||||||
|
|
@ -16,10 +16,10 @@ const app = express();
|
||||||
const _expressWs = require("express-ws")(app);
|
const _expressWs = require("express-ws")(app);
|
||||||
const bodyParser = require("body-parser");
|
const bodyParser = require("body-parser");
|
||||||
const model = require("./db");
|
const model = require("./db");
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = 3000;
|
||||||
|
|
||||||
const db = model.db;
|
const db = model.db;
|
||||||
const wsRouter = express.Router();
|
const wsRouter = express.Router()
|
||||||
|
|
||||||
let viewpath = path.join(__dirname, "views");
|
let viewpath = path.join(__dirname, "views");
|
||||||
let eta = new Eta({ views: viewpath, cache: false, autoEscape: true });
|
let eta = new Eta({ views: viewpath, cache: false, autoEscape: true });
|
||||||
|
|
@ -36,12 +36,7 @@ let routeIndex = Object.freeze({
|
||||||
});
|
});
|
||||||
const wsConnections = new Map();
|
const wsConnections = new Map();
|
||||||
|
|
||||||
const INSPECT_OPTS = {
|
const INSPECT_OPTS = { showHidden: false, depth: null, colors: false, compact: false };
|
||||||
showHidden: false,
|
|
||||||
depth: null,
|
|
||||||
colors: false,
|
|
||||||
compact: false,
|
|
||||||
};
|
|
||||||
function inspectArgs(args) {
|
function inspectArgs(args) {
|
||||||
return args.map((v) => util.inspect(v, INSPECT_OPTS)).join(" ");
|
return args.map((v) => util.inspect(v, INSPECT_OPTS)).join(" ");
|
||||||
}
|
}
|
||||||
|
|
@ -72,8 +67,8 @@ app.use(
|
||||||
app.use("/", wsRouter);
|
app.use("/", wsRouter);
|
||||||
app.use("/plumbing", require("./plumbing"));
|
app.use("/plumbing", require("./plumbing"));
|
||||||
|
|
||||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
|
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY
|
||||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
|
||||||
|
|
||||||
// Configure web-push with your VAPID details
|
// Configure web-push with your VAPID details
|
||||||
webPush.setVapidDetails(
|
webPush.setVapidDetails(
|
||||||
|
|
@ -82,46 +77,11 @@ webPush.setVapidDetails(
|
||||||
vapidPrivateKey,
|
vapidPrivateKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
// KaiOS's push service only understands the legacy draft `aesgcm` content
|
async function saveFile(structureId, req, uploadedFile, asset=false) {
|
||||||
// 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 name = uploadedFile.name;
|
||||||
const [mime_type, mime_subtype] = uploadedFile.mimetype.split("/");
|
const [mime_type, mime_subtype] = uploadedFile.mimetype.split("/");
|
||||||
// structureId may arrive as a number (route.structure_id is an INTEGER) when
|
const uploadPath = path.join(__dirname, "public", structureId);
|
||||||
// called from a sandboxed handler via require('files').saveFile — the workshop
|
const storedPath = path.join(structureId, uploadedFile.name);
|
||||||
// /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 });
|
fs.mkdirSync(uploadPath, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -138,7 +98,7 @@ async function saveFile(structureId, req, uploadedFile, asset = false) {
|
||||||
let file = model.getFile(id);
|
let file = model.getFile(id);
|
||||||
file.url = prefixUrlWithHost(req, file.path);
|
file.url = prefixUrlWithHost(req, file.path);
|
||||||
|
|
||||||
return file;
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
// A console whose log() mirrors to stdout and to the structure's logs table.
|
// A console whose log() mirrors to stdout and to the structure's logs table.
|
||||||
|
|
@ -155,12 +115,7 @@ function makeConsole(structureId, routeId) {
|
||||||
// returning its exports. Each library runs in its own context so nothing
|
// returning its exports. Each library runs in its own context so nothing
|
||||||
// leaks between dbs.
|
// leaks between dbs.
|
||||||
function runLibrary(sql, librarySource, console) {
|
function runLibrary(sql, librarySource, console) {
|
||||||
const libContext = vm.createContext({
|
const libContext = vm.createContext({ sql, console, fetch, module: { exports: null } });
|
||||||
sql,
|
|
||||||
console,
|
|
||||||
fetch,
|
|
||||||
module: { exports: null },
|
|
||||||
});
|
|
||||||
vm.runInContext(librarySource, libContext);
|
vm.runInContext(librarySource, libContext);
|
||||||
return libContext.module.exports;
|
return libContext.module.exports;
|
||||||
}
|
}
|
||||||
|
|
@ -170,33 +125,16 @@ function makeLibs(structureId, console) {
|
||||||
const dbs = {};
|
const dbs = {};
|
||||||
for (let appDb of model.getDbsForStructure(structureId)) {
|
for (let appDb of model.getDbsForStructure(structureId)) {
|
||||||
const sql = model.getDbInstance(appDb.id);
|
const sql = model.getDbInstance(appDb.id);
|
||||||
dbs[appDb.alias] = {
|
dbs[appDb.alias] = { library: runLibrary(sql, appDb.library, console), sql };
|
||||||
library: runLibrary(sql, appDb.library, console),
|
|
||||||
sql,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
eta: model.getTemplater(structureId),
|
eta: model.getTemplater(structureId),
|
||||||
db: (alias) => dbs[alias],
|
db: (alias) => dbs[alias],
|
||||||
push: push,
|
push: webPush,
|
||||||
files: { saveFile: (...args) => saveFile(structureId, ...args) },
|
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) {
|
function bootstrapContext(structureId, routeId, initContext) {
|
||||||
const structure = model.getStructure(structureId);
|
const structure = model.getStructure(structureId);
|
||||||
const console = makeConsole(structureId, routeId);
|
const console = makeConsole(structureId, routeId);
|
||||||
|
|
@ -223,6 +161,7 @@ function routeWithPrefix(route) {
|
||||||
return withPrefix(route.route_prefix, route.path);
|
return withPrefix(route.route_prefix, route.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function compileWebsocketHandler(route) {
|
function compileWebsocketHandler(route) {
|
||||||
try {
|
try {
|
||||||
// Keep the existing WS handler context for compatibility. Restricting the
|
// Keep the existing WS handler context for compatibility. Restricting the
|
||||||
|
|
@ -307,7 +246,6 @@ wsRouter.ws("*", (ws, req) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.clients = clients;
|
ws.clients = clients;
|
||||||
req.currentUser = currentUserFor(req);
|
|
||||||
ws.render = (template, context = {}) => {
|
ws.render = (template, context = {}) => {
|
||||||
const structure = model.getStructure(route.structure_id);
|
const structure = model.getStructure(route.structure_id);
|
||||||
context.route = makeRoute(structure);
|
context.route = makeRoute(structure);
|
||||||
|
|
@ -402,7 +340,6 @@ function headChrome(headInjection) {
|
||||||
<script src="/js/hyperscript.js"></script>
|
<script src="/js/hyperscript.js"></script>
|
||||||
<script src="/js/tailwind.js"></script>
|
<script src="/js/tailwind.js"></script>
|
||||||
<script src="/js/htmx.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="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
|
||||||
<script src="/js/bliss_inspector.js"></script>
|
<script src="/js/bliss_inspector.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -425,17 +362,7 @@ function blissAttrs(source) {
|
||||||
const attrs = {
|
const attrs = {
|
||||||
"data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`,
|
"data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`,
|
||||||
"data-bliss-clone": `/workshop/${source.structureId}/clone_modal/`,
|
"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;
|
if (source.copyUrl) attrs["data-bliss-copy"] = source.copyUrl;
|
||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
@ -458,32 +385,15 @@ function decoratePage(html, { headInjection, source } = {}) {
|
||||||
return $.html();
|
return $.html();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carry rendered head content through HTMX with a neutral OOB wrapper. A
|
// HTMX partial: stamp provenance on the swapped-in elements (an oob swap loses
|
||||||
// literal <head> in an HTMX response is discarded by the browser's fragment
|
// its own attrs, so we descend into its children) and append an out-of-band
|
||||||
// parser, and HTMX does not discover a top-level <style hx-swap-oob>. The div
|
// <head> so head injection still reaches the page.
|
||||||
// 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 } = {}) {
|
function decorateFragment(html, { headInjection, source } = {}) {
|
||||||
// Parse as a document so browser-valid top-level styles and links are
|
const $ = cheerio.load(html, null, false);
|
||||||
// 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);
|
const attrs = blissAttrs(source);
|
||||||
if (attrs) {
|
if (attrs) {
|
||||||
const targets = [];
|
const targets = [];
|
||||||
for (const child of body.children()) {
|
for (const child of $.root().children()) {
|
||||||
if (child.attribs && child.attribs["hx-swap-oob"]) {
|
if (child.attribs && child.attribs["hx-swap-oob"]) {
|
||||||
for (const grandchild of child.children) {
|
for (const grandchild of child.children) {
|
||||||
if (grandchild.attribs) targets.push(grandchild);
|
if (grandchild.attribs) targets.push(grandchild);
|
||||||
|
|
@ -494,29 +404,10 @@ function decorateFragment(html, { headInjection, source } = {}) {
|
||||||
}
|
}
|
||||||
for (const target of targets) Object.assign(target.attribs, attrs);
|
for (const target of targets) Object.assign(target.attribs, attrs);
|
||||||
}
|
}
|
||||||
return body.html() + headOobSwaps($("head").html(), headInjection);
|
return (
|
||||||
}
|
$.html() +
|
||||||
|
`<head id="head" hx-oob-swap="beforeend">${headInjection || ""}</head>`
|
||||||
// `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
|
// Single entry point for turning rendered HTML into a response body. A full
|
||||||
|
|
@ -524,13 +415,13 @@ function fragmentFromPage(html) {
|
||||||
// carrying provenance becomes a decorated fragment; anything else passes through.
|
// carrying provenance becomes a decorated fragment; anything else passes through.
|
||||||
function decorate(html, { headInjection, source, fragment } = {}) {
|
function decorate(html, { headInjection, source, fragment } = {}) {
|
||||||
const lower = html.toLowerCase();
|
const lower = html.toLowerCase();
|
||||||
const isFullDoc = lower.startsWith("<html>") || lower.startsWith("<!doctype");
|
const isFullDoc =
|
||||||
if (!fragment) {
|
lower.startsWith("<html>") || lower.startsWith("<!doctype");
|
||||||
|
if (isFullDoc || !fragment) {
|
||||||
return decoratePage(html, { headInjection, source });
|
return decoratePage(html, { headInjection, source });
|
||||||
}
|
}
|
||||||
const fragmentHtml = isFullDoc ? fragmentFromPage(html) : html;
|
if (source) return decorateFragment(html, { headInjection, source });
|
||||||
if (source) return decorateFragment(fragmentHtml, { headInjection, source });
|
return html;
|
||||||
return fragmentHtml;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One prefix-bound route() helper per structure, shared by every render site.
|
// One prefix-bound route() helper per structure, shared by every render site.
|
||||||
|
|
@ -542,48 +433,12 @@ function renderTemplate(structureId, templateName, context) {
|
||||||
return model.getTemplater(structureId).render(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
|
// Provenance for a rendered response: GET routes are re-embeddable, so they
|
||||||
// carry the copy-embed snippet; other verbs don't.
|
// carry the copy-embed snippet; other verbs don't.
|
||||||
function sourceFor(route, req, templateName = null) {
|
function sourceFor(route, req) {
|
||||||
const structure = model.getStructure(route.structure_id);
|
|
||||||
const template = templateName
|
|
||||||
? model.getTemplateByName(route.structure_id, templateName)
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
structureId: route.structure_id,
|
structureId: route.structure_id,
|
||||||
structureName: structure.name,
|
|
||||||
routeId: route.id,
|
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,
|
copyUrl: route.verb === "GET" ? embedHTML(req.originalUrl) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -622,11 +477,7 @@ function sidebarStuff(structId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get("/workshop/:structure_id", (req, res) => {
|
app.get("/workshop/:structure_id", (req, res) => {
|
||||||
return renderWorkshop(
|
return renderWorkshop(res, "workshop/editor", sidebarStuff(req.params.structure_id));
|
||||||
res,
|
|
||||||
"workshop/editor",
|
|
||||||
sidebarStuff(req.params.structure_id),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/workshop/:structure_id/clone", (req, res) => {
|
app.post("/workshop/:structure_id/clone", (req, res) => {
|
||||||
|
|
@ -711,11 +562,8 @@ app.put("/workshop/:structure_id/route/:route_id", (req, res) => {
|
||||||
model.updateRoute({ ...route, ...req.body });
|
model.updateRoute({ ...route, ...req.body });
|
||||||
if (req.body.scaffold_page) {
|
if (req.body.scaffold_page) {
|
||||||
// TODO: make this send over scaffold page id too someday
|
// TODO: make this send over scaffold page id too someday
|
||||||
const latestPage = model.getLatestScaffoldPage(req.params.route_id);
|
const latestPage = model.getLatestScaffoldPage(req.params.route_id)
|
||||||
model.updateScaffoldPage({
|
model.updateScaffoldPage({ ...latestPage, content: req.body.scaffold_page })
|
||||||
...latestPage,
|
|
||||||
content: req.body.scaffold_page,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
buildRoutes();
|
buildRoutes();
|
||||||
return res.send("good");
|
return res.send("good");
|
||||||
|
|
@ -824,12 +672,9 @@ app.get("/workshop/:structure_id/template/:template_id/preview", (req, res) => {
|
||||||
context.it.route = makeRoute(struct);
|
context.it.route = makeRoute(struct);
|
||||||
|
|
||||||
return res.send(
|
return res.send(
|
||||||
decorate(
|
decorate(renderTemplate(req.params.structure_id, template.name, context.it), {
|
||||||
renderTemplate(req.params.structure_id, template.name, context.it),
|
headInjection: struct.head_injection,
|
||||||
{
|
}),
|
||||||
headInjection: struct.head_injection,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -838,16 +683,16 @@ app.get("/workshop/:structure_id/route/:id", (req, res) => {
|
||||||
let routePrefix = model.getStructure(req.params.structure_id).route_prefix;
|
let routePrefix = model.getStructure(req.params.structure_id).route_prefix;
|
||||||
let previewUrl = null;
|
let previewUrl = null;
|
||||||
|
|
||||||
if (route["verb"] == "GET") {
|
if (route["verb"] == "GET" ) {
|
||||||
previewUrl = routePrefix
|
previewUrl = routePrefix
|
||||||
? path.join(routePrefix || "", route.path)
|
? path.join(routePrefix || "", route.path)
|
||||||
: route.path;
|
: route.path;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
previewUrl = `/workshop/${req.params.structure_id}/route/${req.params.id}/preview`;
|
previewUrl = `/workshop/${req.params.structure_id}/route/${req.params.id}/preview`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const template =
|
const template = route["verb"] == "GET" ? "workshop/route" : "workshop/cud_route";
|
||||||
route["verb"] == "GET" ? "workshop/route" : "workshop/cud_route";
|
|
||||||
|
|
||||||
return renderWorkshop(res, template, {
|
return renderWorkshop(res, template, {
|
||||||
route: route,
|
route: route,
|
||||||
|
|
@ -898,13 +743,15 @@ app.post("/workshop/:structure_id/files", async (req, res) => {
|
||||||
const targetFile = req.files.file;
|
const targetFile = req.files.file;
|
||||||
|
|
||||||
try {
|
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 }));
|
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) {
|
function prefixUrlWithHost(req, path) {
|
||||||
|
|
@ -999,11 +846,6 @@ app.all("*", async (req, res) => {
|
||||||
req.params = routeMatch.params;
|
req.params = routeMatch.params;
|
||||||
const route = routeMatch.route;
|
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);
|
const structure = model.getStructure(route.structure_id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1020,18 +862,14 @@ app.all("*", async (req, res) => {
|
||||||
res.send(
|
res.send(
|
||||||
decorate(renderTemplate(route.structure_id, template, context), {
|
decorate(renderTemplate(route.structure_id, template, context), {
|
||||||
headInjection: structure.head_injection,
|
headInjection: structure.head_injection,
|
||||||
source: sourceFor(route, req, template),
|
source: sourceFor(route, req),
|
||||||
fragment: req.headers["hx-request"],
|
fragment: req.headers["hx-request"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const handlerScript = new vm.Script(route.handler, {
|
const handlerScript = new vm.Script(route.handler, { filename: `handler_${route.id}.js` });
|
||||||
filename: `handler_${route.id}.js`,
|
|
||||||
});
|
|
||||||
// Prepare the execution code that invokes `handler(req, res)` and awaits it
|
// Prepare the execution code that invokes `handler(req, res)` and awaits it
|
||||||
const executionScript = new vm.Script(`handler(req, res); `, {
|
const executionScript = new vm.Script(`handler(req, res); `, { filename: `execution_${route.id}.js` });
|
||||||
filename: `execution_${route.id}.js`,
|
|
||||||
});
|
|
||||||
await handlerScript.runInContext(context);
|
await handlerScript.runInContext(context);
|
||||||
const result = await executionScript.runInContext(context);
|
const result = await executionScript.runInContext(context);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,392 +1,120 @@
|
||||||
(function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
if (window.__blissInspectorLoaded) return;
|
// Create the magnifying glass emoji button
|
||||||
window.__blissInspectorLoaded = true;
|
const magnifyingGlass = document.createElement("div");
|
||||||
|
magnifyingGlass.innerHTML = "🔍";
|
||||||
|
magnifyingGlass.className =
|
||||||
|
"fixed bottom-2 right-2 cursor-pointer text-2xl z-50";
|
||||||
|
document.body.appendChild(magnifyingGlass);
|
||||||
|
|
||||||
const ROUTE_SELECTOR = "[data-bliss-structure-id][data-bliss-route-id]";
|
let highlighted = false;
|
||||||
const TEMPLATE_SELECTOR = "[data-bliss-template-id]";
|
|
||||||
let root = null;
|
|
||||||
let open = false;
|
|
||||||
|
|
||||||
function visibleElements(selector) {
|
// Function to highlight elements
|
||||||
return [...document.querySelectorAll(selector)].filter(
|
function highlightElements() {
|
||||||
(element) => !element.closest("[data-bliss-ui]"),
|
const modal = document.createElement("div");
|
||||||
);
|
modal.id = "inspector-modal";
|
||||||
}
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
function routeElements() {
|
const elements = document.querySelectorAll("[data-bliss-route]");
|
||||||
return visibleElements(ROUTE_SELECTOR);
|
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 templateElements() {
|
// Create the edit icon
|
||||||
return visibleElements(TEMPLATE_SELECTOR);
|
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 templatesFor(element) {
|
// Create the copy icon
|
||||||
try {
|
if (el.getAttribute("data-bliss-copy")) {
|
||||||
const templates = JSON.parse(element.dataset.blissTemplates || "[]");
|
const copyIcon = document.createElement("button");
|
||||||
if (templates.length) return templates;
|
copyIcon.innerHTML = "📋";
|
||||||
} catch (_) {}
|
copyIcon.className =
|
||||||
return element.dataset.blissTemplateId
|
"text-lg bg-white rounded-full p-1 shadow copy-icon";
|
||||||
? [
|
controls.appendChild(copyIcon);
|
||||||
{
|
copyIcon.addEventListener("click", () => {
|
||||||
id: element.dataset.blissTemplateId,
|
navigator.clipboard
|
||||||
name: element.dataset.blissTemplateName,
|
.writeText(el.getAttribute("data-bliss-copy"))
|
||||||
},
|
.then(() => {
|
||||||
]
|
copyIcon.innerHTML = "✅";
|
||||||
: [];
|
})
|
||||||
}
|
.catch((err) => {
|
||||||
|
copyIcon.innerHTML = "❌";
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishInventory() {
|
// Create the clone icon
|
||||||
document.dispatchEvent(
|
const cloneIcon = document.createElement("div");
|
||||||
new CustomEvent("bliss:inventory", { detail: inventory() }),
|
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");
|
||||||
|
|
||||||
function matches(element, { kind, id }) {
|
controls.setAttribute(
|
||||||
if (kind === "template") {
|
"_",
|
||||||
return templatesFor(element).some(
|
`on mouseover set my.style.zIndex to 10000
|
||||||
(template) => String(template.id) === String(id),
|
on mouseout set my.style.zIndex to "initial"
|
||||||
);
|
`,
|
||||||
}
|
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
|
controls.appendChild(cloneIcon);
|
||||||
|
|
||||||
|
el.appendChild(controls);
|
||||||
|
htmx.process(controls);
|
||||||
|
_hyperscript.processNode(controls);
|
||||||
}
|
}
|
||||||
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(
|
// Function to remove highlights
|
||||||
element,
|
function removeHighlights() {
|
||||||
templateId,
|
const elements = document.querySelectorAll(
|
||||||
previousRoots = [],
|
"[data-bliss-route].highlighted",
|
||||||
) {
|
|
||||||
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();
|
elements.forEach((el) => {
|
||||||
for (const element of elements) {
|
el.classList.remove(
|
||||||
const contextId = contextIdFor(element, templateId);
|
"border-2",
|
||||||
const key = contextId || `element:${Math.random()}`;
|
"border-yellow-500",
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
"relative",
|
||||||
groups.get(key).push(element);
|
"highlighted",
|
||||||
|
"p-2",
|
||||||
|
);
|
||||||
|
el.querySelectorAll(".inspector-controls").forEach((el) => el.remove());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
magnifyingGlass.addEventListener("click", function () {
|
||||||
|
if (!highlighted) {
|
||||||
|
highlightElements();
|
||||||
|
magnifyingGlass.innerHTML = "❌";
|
||||||
|
} else {
|
||||||
|
removeHighlights();
|
||||||
|
magnifyingGlass.innerHTML = "🔍";
|
||||||
}
|
}
|
||||||
return [...groups.values()];
|
highlighted = !highlighted;
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("bliss:highlight", (event) =>
|
// Observe the document for changes
|
||||||
highlight(event.detail),
|
// const observer = new MutationObserver(function (mutations) {
|
||||||
);
|
// if (highlighted) {
|
||||||
document.addEventListener("bliss:source-saved", (event) =>
|
// highlightElements();
|
||||||
refreshSources(event.detail),
|
// }
|
||||||
);
|
// });
|
||||||
document.addEventListener("bliss:inventory-request", publishInventory);
|
|
||||||
document.addEventListener("bliss:inspector-close", hide);
|
// // Configure the observer
|
||||||
document.addEventListener("htmx:afterSwap", () => {
|
// observer.observe(document.body, {
|
||||||
if (open) publishInventory();
|
// childList: true,
|
||||||
});
|
// subtree: true,
|
||||||
})();
|
// });
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""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