diff --git a/bliss-cli/live-editor/inspector.js b/bliss-cli/live-editor/inspector.js
new file mode 100644
index 0000000..b4f228d
--- /dev/null
+++ b/bliss-cli/live-editor/inspector.js
@@ -0,0 +1,3 @@
+function handler(req, res) {
+ res.render("inspector/slideout", {});
+}
diff --git a/bliss-cli/live-editor/install.js b/bliss-cli/live-editor/install.js
new file mode 100644
index 0000000..d7eebcd
--- /dev/null
+++ b/bliss-cli/live-editor/install.js
@@ -0,0 +1,79 @@
+#!/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"],
+];
+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}`);
diff --git a/bliss-cli/live-editor/route-editor.eta b/bliss-cli/live-editor/route-editor.eta
new file mode 100644
index 0000000..212859e
--- /dev/null
+++ b/bliss-cli/live-editor/route-editor.eta
@@ -0,0 +1,28 @@
+
+
<%= it.sourceRoute.verb %> <%= it.sourceRoute.path %>
+ <% if (it.sourceRoute.verb !== "GET") { %>
+
Rerunning this <%= it.sourceRoute.verb %> request can repeat writes or external side effects. Bliss will not rerun it automatically.
+ <% } %>
+
<%= it.sourceRoute.handler %>
+
+
+
diff --git a/bliss-cli/live-editor/route-editor.js b/bliss-cli/live-editor/route-editor.js
new file mode 100644
index 0000000..7b1a13d
--- /dev/null
+++ b/bliss-cli/live-editor/route-editor.js
@@ -0,0 +1,8 @@
+function handler(req, res) {
+ const sql = require("db")("bliss").sql;
+ const sourceRoute = sql
+ .prepare("SELECT * FROM routes WHERE id = ?")
+ .get(req.params.id);
+ if (!sourceRoute) return res.status(404).send("Route not found");
+ res.render("inspector/route_editor", { sourceRoute });
+}
diff --git a/bliss-cli/live-editor/slideout.eta b/bliss-cli/live-editor/slideout.eta
new file mode 100644
index 0000000..4acf7b3
--- /dev/null
+++ b/bliss-cli/live-editor/slideout.eta
@@ -0,0 +1,76 @@
+
+
diff --git a/bliss-cli/live-editor/template-editor.eta b/bliss-cli/live-editor/template-editor.eta
new file mode 100644
index 0000000..f2aa49d
--- /dev/null
+++ b/bliss-cli/live-editor/template-editor.eta
@@ -0,0 +1,25 @@
+
+
<%= it.sourceTemplate.name %>
+
<%= it.sourceTemplate.content %>
+
+
+
diff --git a/bliss-cli/live-editor/template-editor.js b/bliss-cli/live-editor/template-editor.js
new file mode 100644
index 0000000..e97f348
--- /dev/null
+++ b/bliss-cli/live-editor/template-editor.js
@@ -0,0 +1,8 @@
+function handler(req, res) {
+ const sql = require("db")("bliss").sql;
+ const sourceTemplate = sql
+ .prepare("SELECT * FROM templates WHERE id = ?")
+ .get(req.params.id);
+ if (!sourceTemplate) return res.status(404).send("Template not found");
+ res.render("inspector/template_editor", { sourceTemplate });
+}
diff --git a/db.js b/db.js
index 83f7ce0..0bcf33e 100644
--- a/db.js
+++ b/db.js
@@ -3,6 +3,7 @@ const path = require("path");
const betterSqlite3 = require("better-sqlite3");
const { LRUCache } = require("lru-cache");
const { Eta } = require("eta");
+const cheerio = require("cheerio");
const db = betterSqlite3("./dbs/0.sqlite");
@@ -91,6 +92,37 @@ function getAllRoutes() {
const templateCache = new LRUCache({ max: 100 });
+function annotateTemplateHtml(html, template) {
+ 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));
+ if (!$(element).attr("data-bliss-template-id")) {
+ $(element).attr({
+ "data-bliss-template-id": String(template.id),
+ "data-bliss-template-name": template.name,
+ });
+ }
+ }
+ const lower = html.trimStart().toLowerCase();
+ if (lower.startsWith("
+