@shop-on/theme-dev-host 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,10 +63,10 @@ load one state, tweak, and Save back to it — or branch to a fresh name with Sa
63
63
  as preset. Settings are **merged** into the preset map, so sibling presets are
64
64
  never clobbered.
65
65
 
66
- Without `--tunnel` the gateway is localhost-only. With `--tunnel`, paste the
67
- printed public URL into the **hosted** admin theme-editor (actions menu → "Local
68
- dev preview") to preview your local theme there — no backend or local admin
69
- needed. HMR there dials `ws://localhost:<theme-port>`, so it only works in a
66
+ Without `--tunnel` the gateway is localhost-only. To preview your local theme in
67
+ the **hosted** admin theme-editor, run `shop-on theme dev` instead: it starts this
68
+ gateway and a tunnel, and registers the tunnel URL on your dev install, the only
69
+ way the editor learns it — no backend or local admin needed. HMR there dials `ws://localhost:<theme-port>`, so it only works in a
70
70
  browser on the same machine as the dev server. See
71
71
  [docs/theme-dev-host.md](../../docs/theme-dev-host.md).
72
72
 
package/dist/auth.mjs CHANGED
@@ -2,57 +2,72 @@
2
2
  import { EditorAuthError } from "./verify.mjs";
3
3
  //#region src/auth.ts
4
4
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ const SID_TTL_MS = 18e5;
6
+ const MAX_SIDS = 100;
5
7
  function reject(c, reason) {
6
8
  console.warn(`theme-dev-host: 401 ${c.req.method} ${c.req.path} — ${reason}`);
7
9
  return c.json({ error: reason }, 401);
8
10
  }
9
- async function applyJwt(c, header, verify) {
10
- const claims = await verify(header.slice(7));
11
- const storeSlug = c.req.param("storeSlug") ?? "";
12
- const installId = c.req.param("installId") ?? "";
13
- if (claims.shop_handle !== storeSlug) return reject(c, `shop mismatch (token shop_handle=${claims.shop_handle}, path=${storeSlug})`);
14
- if (claims.install_id !== installId) return reject(c, `install mismatch (token install_id=${claims.install_id}, path=${installId})`);
15
- c.set("sid", claims.sid);
16
- c.set("shopHandle", storeSlug);
17
- c.set("installId", installId);
18
- return null;
19
- }
20
11
  function createEditorAuth(verify) {
21
- const editorAuth = async (c, next) => {
22
- const header = c.req.header("Authorization");
23
- if (!header?.startsWith("Bearer ")) return reject(c, "missing bearer token");
12
+ const sids = /* @__PURE__ */ new Map();
13
+ function registerSid(sid) {
14
+ sids.delete(sid);
15
+ sids.set(sid, Date.now() + SID_TTL_MS);
16
+ if (sids.size > MAX_SIDS) sids.delete(sids.keys().next().value);
17
+ }
18
+ function sidLive(sid) {
19
+ const expires = sids.get(sid);
20
+ return expires !== void 0 && expires > Date.now();
21
+ }
22
+ async function applyJwt(c, header, scope) {
23
+ let claims;
24
24
  try {
25
- const fail = await applyJwt(c, header, verify);
26
- if (fail) return fail;
25
+ claims = await verify(header.slice(7));
27
26
  } catch (err) {
28
27
  if (err instanceof EditorAuthError) return reject(c, err.reason);
29
28
  throw err;
30
29
  }
31
- await next();
32
- };
30
+ if (!claims.scp.includes(scope)) return reject(c, `wrong scope (route takes ${scope})`);
31
+ const storeSlug = c.req.param("storeSlug") ?? "";
32
+ const installId = c.req.param("installId") ?? "";
33
+ if (claims.shop_handle !== storeSlug) return reject(c, `shop mismatch (token shop_handle=${claims.shop_handle}, path=${storeSlug})`);
34
+ if (claims.install_id !== installId) return reject(c, `install mismatch (token install_id=${claims.install_id}, path=${installId})`);
35
+ if (scope === "editor") registerSid(claims.sid);
36
+ c.set("sid", claims.sid);
37
+ c.set("shopHandle", storeSlug);
38
+ c.set("installId", installId);
39
+ c.set("userId", claims.sub ?? "");
40
+ return null;
41
+ }
42
+ function bearerOnly(scope) {
43
+ return async (c, next) => {
44
+ const header = c.req.header("Authorization");
45
+ if (!header?.startsWith("Bearer ")) return reject(c, "missing bearer token");
46
+ const fail = await applyJwt(c, header, scope);
47
+ if (fail) return fail;
48
+ await next();
49
+ };
50
+ }
33
51
  const editorAuthOrPreview = async (c, next) => {
34
52
  const header = c.req.header("Authorization");
35
53
  if (header?.startsWith("Bearer ")) {
36
- try {
37
- const fail = await applyJwt(c, header, verify);
38
- if (fail) return fail;
39
- } catch (err) {
40
- if (err instanceof EditorAuthError) return reject(c, err.reason);
41
- throw err;
42
- }
54
+ const fail = await applyJwt(c, header, "editor");
55
+ if (fail) return fail;
43
56
  await next();
44
57
  return;
45
58
  }
46
59
  if (c.req.method !== "GET") return reject(c, "missing bearer token");
47
60
  const sid = c.req.query("sid") ?? c.req.header("X-Editor-Sid") ?? void 0;
48
61
  if (!sid || !UUID_RE.test(sid)) return reject(c, "missing or invalid sid");
62
+ if (!sidLive(sid)) return reject(c, "unknown sid (open the editor again to start a session)");
49
63
  c.set("sid", sid);
50
64
  c.set("shopHandle", c.req.param("storeSlug") ?? "");
51
65
  c.set("installId", c.req.param("installId") ?? "");
52
66
  await next();
53
67
  };
54
68
  return {
55
- editorAuth,
69
+ editorAuth: bearerOnly("editor"),
70
+ publishAuth: bearerOnly("editor:publish"),
56
71
  editorAuthOrPreview
57
72
  };
58
73
  }
package/dist/drafts.mjs CHANGED
@@ -1,44 +1,169 @@
1
1
  #!/usr/bin/env node
2
+ import { applyOps, touchedEntries } from "@shop-on/theme-contract/ops";
2
3
  //#region src/drafts.ts
3
- const sessions = /* @__PURE__ */ new Map();
4
- const MAX_SESSIONS = 100;
5
- function sessionFor(sid) {
6
- let s = sessions.get(sid);
7
- if (!s) {
8
- if (sessions.size >= MAX_SESSIONS) {
9
- const oldest = sessions.keys().next().value;
10
- if (oldest !== void 0) sessions.delete(oldest);
4
+ /** Batches kept for `GET /draft/ops`; a client further behind gets `{reset}`. */
5
+ const RING_SIZE = 256;
6
+ const READ_COUNT = 32;
7
+ const READ_BYTES = 524288;
8
+ function createDraftStore() {
9
+ const drafts = /* @__PURE__ */ new Map();
10
+ function draftFor(key) {
11
+ let d = drafts.get(key);
12
+ if (!d) {
13
+ d = {
14
+ seq: 0,
15
+ publishedSeq: 0,
16
+ templates: /* @__PURE__ */ new Map(),
17
+ groups: /* @__PURE__ */ new Map(),
18
+ clients: /* @__PURE__ */ new Map(),
19
+ ring: []
20
+ };
21
+ drafts.set(key, d);
11
22
  }
12
- s = {
13
- templates: {},
14
- groups: {}
15
- };
16
- sessions.set(sid, s);
23
+ return d;
17
24
  }
18
- return s;
19
- }
20
- const drafts = {
21
- getSettings(sid) {
22
- return sessions.get(sid)?.settings;
23
- },
24
- setSettings(sid, settings) {
25
- sessionFor(sid).settings = settings;
26
- },
27
- getTemplate(sid, templateId) {
28
- return sessions.get(sid)?.templates[templateId];
29
- },
30
- setTemplate(sid, templateId, template) {
31
- sessionFor(sid).templates[templateId] = template;
32
- },
33
- getGroups(sid) {
34
- return { ...sessions.get(sid)?.groups };
35
- },
36
- setGroup(sid, groupType, group) {
37
- sessionFor(sid).groups[groupType] = group;
38
- },
39
- clear(sid) {
40
- sessions.delete(sid);
25
+ function push(d, batch) {
26
+ d.ring.push(batch);
27
+ if (d.ring.length > RING_SIZE) d.ring.shift();
41
28
  }
42
- };
29
+ return {
30
+ seq(key) {
31
+ return drafts.get(key)?.seq ?? 0;
32
+ },
33
+ publishedSeq(key) {
34
+ return drafts.get(key)?.publishedSeq ?? 0;
35
+ },
36
+ hasUnpublishedEdits(key) {
37
+ const d = drafts.get(key);
38
+ return d !== void 0 && d.seq > d.publishedSeq;
39
+ },
40
+ /** The draft's settings, or undefined when the draft never touched them. */
41
+ settings(key) {
42
+ return drafts.get(key)?.settings;
43
+ },
44
+ /** undefined: not in the draft (read disk); null: deleted in the draft. */
45
+ template(key, id) {
46
+ return drafts.get(key)?.templates.get(id);
47
+ },
48
+ templates(key) {
49
+ return new Map(drafts.get(key)?.templates);
50
+ },
51
+ groups(key) {
52
+ return new Map(drafts.get(key)?.groups);
53
+ },
54
+ /**
55
+ * Apply one batch. Synchronous, so nothing interleaves between the read
56
+ * of the prior entries and the write. `disk` holds the disk copy of every
57
+ * touched entry; the draft's own copy wins where it has one.
58
+ */
59
+ apply(key, body, userId, disk) {
60
+ const d = draftFor(key);
61
+ const client = d.clients.get(body.clientId);
62
+ if (client) {
63
+ if (body.batchNo === client.batchNo) return {
64
+ ok: true,
65
+ seq: client.seq
66
+ };
67
+ if (body.batchNo < client.batchNo) return {
68
+ ok: false,
69
+ status: 409,
70
+ error: "stale batch"
71
+ };
72
+ }
73
+ const touched = touchedEntries(body.ops);
74
+ const prior = {
75
+ settings: {},
76
+ templates: {},
77
+ groups: {}
78
+ };
79
+ if (touched.settings) prior.settings = d.settings ?? disk.settings;
80
+ for (const id of touched.templates) {
81
+ const entry = d.templates.has(id) ? d.templates.get(id) : disk.templates[id];
82
+ if (entry) prior.templates[id] = entry;
83
+ }
84
+ for (const type of touched.groups) {
85
+ const entry = d.groups.has(type) ? d.groups.get(type) : disk.groups[type];
86
+ if (entry) prior.groups[type] = entry;
87
+ }
88
+ const next = applyOps(prior, body.ops);
89
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
90
+ if (!(touched.settings && !same(prior.settings, next.settings) || [...touched.templates].some((id) => !same(prior.templates[id], next.templates[id])) || [...touched.groups].some((type) => !same(prior.groups[type], next.groups[type])))) {
91
+ d.clients.set(body.clientId, {
92
+ batchNo: body.batchNo,
93
+ seq: d.seq
94
+ });
95
+ return {
96
+ ok: true,
97
+ seq: d.seq
98
+ };
99
+ }
100
+ if (touched.settings) d.settings = next.settings;
101
+ for (const id of touched.templates) d.templates.set(id, next.templates[id] ?? null);
102
+ for (const type of touched.groups) d.groups.set(type, next.groups[type] ?? null);
103
+ d.seq += 1;
104
+ d.clients.set(body.clientId, {
105
+ batchNo: body.batchNo,
106
+ seq: d.seq
107
+ });
108
+ push(d, {
109
+ seq: d.seq,
110
+ clientId: body.clientId,
111
+ batchId: body.batchId,
112
+ userId,
113
+ ops: body.ops
114
+ });
115
+ return {
116
+ ok: true,
117
+ seq: d.seq
118
+ };
119
+ },
120
+ /** Drop every edit (publish / discard): the next read is the disk copy. */
121
+ reset(key, reset) {
122
+ const d = draftFor(key);
123
+ d.settings = void 0;
124
+ d.templates.clear();
125
+ d.groups.clear();
126
+ d.clients.clear();
127
+ d.seq += 1;
128
+ d.publishedSeq = d.seq;
129
+ push(d, {
130
+ seq: d.seq,
131
+ clientId: "",
132
+ batchId: "",
133
+ userId: "",
134
+ ops: [],
135
+ reset
136
+ });
137
+ return d.seq;
138
+ },
139
+ /** Batches after `since`, or `{reset}` when the ring cannot catch the client up. */
140
+ readSince(key, since) {
141
+ const d = drafts.get(key);
142
+ const seq = d?.seq ?? 0;
143
+ if (since === seq) return {
144
+ seq,
145
+ batches: []
146
+ };
147
+ const ring = d?.ring ?? [];
148
+ if (since > seq || ring.length === 0 || since < ring[0].seq - 1) return {
149
+ reset: true,
150
+ seq
151
+ };
152
+ const after = ring.filter((b) => b.seq > since);
153
+ const batches = [];
154
+ let bytes = 0;
155
+ for (const batch of after) {
156
+ batches.push(batch);
157
+ bytes += Buffer.byteLength(JSON.stringify(batch.ops));
158
+ if (batches.length === READ_COUNT || bytes >= READ_BYTES) break;
159
+ }
160
+ return {
161
+ seq,
162
+ batches,
163
+ ...batches.length < after.length ? { more: true } : {}
164
+ };
165
+ }
166
+ };
167
+ }
43
168
  //#endregion
44
- export { drafts };
169
+ export { createDraftStore };
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { LANG_RE, loadLocale, loadStorefrontLocale, parseLang, readJsonDir, resolveDisplayName, resolveTranslations, unwrapSettings } from "./locale.mjs";
3
- import { drafts } from "./drafts.mjs";
3
+ import { createDraftStore } from "./drafts.mjs";
4
+ import { loadRichTextSanitizer } from "./richtext.mjs";
4
5
  import { isValidPresetHandle, writeSettingsPreset, writeThemeSourceFiles } from "./save.mjs";
5
6
  import "./auth.mjs";
6
7
  import path from "node:path";
@@ -8,14 +9,19 @@ import "hono";
8
9
  import { readFile, readdir, stat } from "node:fs/promises";
9
10
  import { currentPresetHandle } from "@shop-on/theme-contract/preset-listing";
10
11
  import { bodyLimit } from "hono/body-limit";
12
+ import { MAX_BATCH_BYTES, OpBatchBodySchema, chunkOps, touchedEntries } from "@shop-on/theme-contract/ops";
13
+ import { applyEditorTool, isEditorToolName } from "@shop-on/theme-contract/editor-tools";
14
+ import { readThemeAiDir } from "@shop-on/theme-contract/ai-files";
15
+ import { invalidTemplateId } from "@shop-on/theme-contract/store-path";
11
16
  //#region src/editor-app.ts
12
- const TEMPLATE_ID_RE = /^[A-Za-z0-9_.-]+$/;
13
- const invalidTemplateId = (id) => !id || !TEMPLATE_ID_RE.test(id) || /[/\\]|\.\./.test(id);
14
17
  const PREFIX = "/api/stores/:storeSlug/:installId";
18
+ const PROTOCOL_VERSION = 2;
15
19
  function registerEditorRoutes(app, configDir, auth, store, opts) {
16
- const { editorAuth, editorAuthOrPreview } = auth;
20
+ const { editorAuth, publishAuth, editorAuthOrPreview } = auth;
17
21
  const themeSrcDir = opts?.themeSrcDir;
18
22
  const presetOverride = opts?.preset;
23
+ const drafts = createDraftStore();
24
+ const draftKey = (c) => `${c.req.param("storeSlug")}/${c.req.param("installId")}`;
19
25
  if (store) app.use(`${PREFIX}/*`, async (c, next) => {
20
26
  const slug = c.req.param("storeSlug");
21
27
  const install = c.req.param("installId");
@@ -29,6 +35,10 @@ function registerEditorRoutes(app, configDir, auth, store, opts) {
29
35
  maxSize: 8388608,
30
36
  onError: (c) => c.json({ error: "draft too large" }, 413)
31
37
  });
38
+ const opsBodyLimit = bodyLimit({
39
+ maxSize: MAX_BATCH_BYTES,
40
+ onError: (c) => c.json({ error: "batch too large" }, 413)
41
+ });
32
42
  async function loadSettingsData() {
33
43
  try {
34
44
  return JSON.parse(await readFile(path.join(configDir, "settings-data.json"), "utf-8"));
@@ -46,13 +56,13 @@ function registerEditorRoutes(app, configDir, auth, store, opts) {
46
56
  return null;
47
57
  }
48
58
  }
49
- async function sectionGroupsFor(sid) {
50
- const groups = drafts.getGroups(sid);
59
+ async function diskGroups() {
60
+ const groups = {};
51
61
  try {
52
62
  for (const file of await readdir(path.join(configDir, "section-groups"))) {
53
63
  if (!file.endsWith(".json")) continue;
54
64
  const data = JSON.parse(await readFile(path.join(configDir, "section-groups", file), "utf-8"));
55
- if (data.type && !groups[data.type]) groups[data.type] = {
65
+ if (data.type) groups[data.type] = {
56
66
  sections: data.sections ?? {},
57
67
  order: data.order ?? []
58
68
  };
@@ -60,8 +70,31 @@ function registerEditorRoutes(app, configDir, auth, store, opts) {
60
70
  } catch {}
61
71
  return groups;
62
72
  }
73
+ async function sectionGroupsFor(key) {
74
+ const groups = await diskGroups();
75
+ for (const [type, entry] of drafts.groups(key)) if (entry) groups[type] = entry;
76
+ else delete groups[type];
77
+ return groups;
78
+ }
79
+ async function diskPrior(ops) {
80
+ const touched = touchedEntries(ops);
81
+ const prior = {
82
+ settings: {},
83
+ templates: {},
84
+ groups: {}
85
+ };
86
+ if (touched.settings) prior.settings = await liveSettings();
87
+ for (const id of touched.templates) {
88
+ const tpl = await diskTemplate(id);
89
+ if (tpl) prior.templates[id] = tpl;
90
+ }
91
+ if (touched.groups.size > 0) prior.groups = await diskGroups();
92
+ return prior;
93
+ }
63
94
  app.get(`${PREFIX}/editor-loader`, editorAuthOrPreview, async (c) => {
64
- const sid = c.get("sid");
95
+ const key = draftKey(c);
96
+ const seq = drafts.seq(key);
97
+ const publishedSeq = drafts.publishedSeq(key);
65
98
  const locale = await loadLocale(configDir, parseLang(c));
66
99
  const [schemaRaw, sections, blocks, templates, sectionGroups] = await Promise.all([
67
100
  readFile(path.join(configDir, "settings-schema.json"), "utf-8").catch(() => null),
@@ -72,14 +105,20 @@ function registerEditorRoutes(app, configDir, auth, store, opts) {
72
105
  ]);
73
106
  const themeSchema = (schemaRaw ? JSON.parse(schemaRaw) : []).map((s) => resolveTranslations(s, locale));
74
107
  const settingsData = await loadSettingsData();
75
- const settings = drafts.getSettings(sid) ?? unwrapSettings(settingsData, presetOverride);
108
+ const settings = drafts.settings(key) ?? unwrapSettings(settingsData, presetOverride);
76
109
  const activePreset = presetOverride ?? settingsData.current?.preset ?? "default";
110
+ const draftIndex = drafts.template(key, "index");
77
111
  const initialTemplateData = {
78
112
  sections: {},
79
113
  order: [],
80
- ...drafts.getTemplate(sid, "index") ?? await diskTemplate("index")
114
+ ...draftIndex === void 0 ? await diskTemplate("index") : draftIndex
81
115
  };
82
- initialTemplateData.section_groups = await sectionGroupsFor(sid);
116
+ initialTemplateData.section_groups = await sectionGroupsFor(key);
117
+ const diskIds = new Set(templates.map(({ id }) => id));
118
+ const draftOnly = [...drafts.templates(key)].filter(([id, entry]) => entry && !diskIds.has(id)).map(([id, entry]) => ({
119
+ id,
120
+ name: resolveDisplayName(entry, id, locale)
121
+ }));
83
122
  let layoutSchema = {};
84
123
  try {
85
124
  layoutSchema = resolveTranslations(JSON.parse(await readFile(path.join(configDir, "layouts", "main.json"), "utf-8")), locale);
@@ -94,37 +133,168 @@ function registerEditorRoutes(app, configDir, auth, store, opts) {
94
133
  type: id,
95
134
  ...resolveTranslations(data, locale)
96
135
  })),
97
- templates: templates.map(({ id, data }) => ({
136
+ templates: [...templates.map(({ id, data }) => ({
98
137
  id,
99
138
  name: resolveDisplayName(data, id, locale)
100
- })),
139
+ })), ...draftOnly],
101
140
  sectionGroupDefaults: sectionGroups.map(({ data }) => resolveTranslations(data, locale)),
102
141
  initialTemplateData,
103
142
  layoutSchema,
104
143
  settings,
105
- activePreset
144
+ activePreset,
145
+ seq,
146
+ publishedSeq,
147
+ protocolVersion: PROTOCOL_VERSION
106
148
  });
107
149
  });
108
- app.put(`${PREFIX}/draft`, editorAuth, writeBodyLimit, async (c) => {
109
- const body = await c.req.json().catch(() => null);
110
- if (!body?.settings || typeof body.settings !== "object") return c.json({ error: "settings required" }, 400);
111
- drafts.setSettings(c.get("sid"), body.settings);
112
- return c.json({ ok: true });
150
+ for (const route of [
151
+ "/draft",
152
+ "/draft/template",
153
+ "/draft/section-group"
154
+ ]) app.put(`${PREFIX}${route}`, (c) => c.json({ error: "Draft PUTs were replaced by POST /draft/ops; reload the editor." }, 410));
155
+ app.post(`${PREFIX}/draft/ops`, opsBodyLimit, editorAuth, async (c) => {
156
+ const raw = await c.req.json().catch(() => void 0);
157
+ if (raw === void 0) return c.json({ error: "invalid JSON body" }, 400);
158
+ const parsed = OpBatchBodySchema.safeParse(raw);
159
+ if (!parsed.success) return c.json({
160
+ error: "invalid batch",
161
+ issues: parsed.error.issues.slice(0, 5)
162
+ }, 422);
163
+ const key = draftKey(c);
164
+ const prior = await diskPrior(parsed.data.ops);
165
+ const result = drafts.apply(key, parsed.data, c.get("userId"), prior);
166
+ if (!result.ok) return c.json({ error: result.error }, result.status);
167
+ return c.json({ seq: result.seq });
113
168
  });
114
- app.put(`${PREFIX}/draft/template`, editorAuth, writeBodyLimit, async (c) => {
115
- const body = await c.req.json().catch(() => null);
116
- if (invalidTemplateId(body?.templateId) || !body?.template) return c.json({ error: "templateId and template required" }, 400);
117
- drafts.setTemplate(c.get("sid"), body.templateId, body.template);
118
- return c.json({ ok: true });
169
+ app.get(`${PREFIX}/draft/ops`, editorAuth, (c) => {
170
+ const raw = c.req.query("since") ?? "";
171
+ if (!/^\d{1,15}$/.test(raw)) return c.json({ error: "since must be a non-negative integer" }, 400);
172
+ return c.json(drafts.readSince(draftKey(c), Number(raw)));
119
173
  });
120
- app.put(`${PREFIX}/draft/section-group`, editorAuth, writeBodyLimit, async (c) => {
174
+ async function engineInputs(lang) {
175
+ const locale = await loadLocale(configDir, lang);
176
+ const [schemaRaw, sections, blocks, ai] = await Promise.all([
177
+ readFile(path.join(configDir, "settings-schema.json"), "utf-8").catch(() => null),
178
+ readJsonDir(path.join(configDir, "sections")),
179
+ readJsonDir(path.join(configDir, "blocks")),
180
+ readThemeAiDir(path.join(configDir, "ai"))
181
+ ]);
182
+ for (const { reason } of ai.skipped) console.warn(`[editor-tools] src/ai/: skipped ${reason}`);
183
+ const resolved = (items) => items.map(({ id, data }) => ({
184
+ type: id,
185
+ ...resolveTranslations(data, locale)
186
+ }));
187
+ return {
188
+ schemas: {
189
+ settingsSchema: (schemaRaw ? JSON.parse(schemaRaw) : []).map((s) => resolveTranslations(s, locale)),
190
+ sections: resolved(sections),
191
+ blocks: resolved(blocks)
192
+ },
193
+ recipes: ai.recipes,
194
+ guide: ai.guide
195
+ };
196
+ }
197
+ async function documentFor(key) {
198
+ const templates = {};
199
+ for (const { id, data } of await readJsonDir(path.join(configDir, "templates"))) templates[id] = data;
200
+ for (const [id, entry] of drafts.templates(key)) if (entry) templates[id] = entry;
201
+ else delete templates[id];
202
+ return {
203
+ settings: drafts.settings(key) ?? await liveSettings(),
204
+ templates,
205
+ groups: await sectionGroupsFor(key)
206
+ };
207
+ }
208
+ app.post(`${PREFIX}/editor-tools/:tool`, opsBodyLimit, editorAuth, async (c) => {
209
+ const tool = c.req.param("tool");
210
+ if (tool !== "apply_ops" && !isEditorToolName(tool)) return c.json({ error: "unknown tool" }, 404);
121
211
  const body = await c.req.json().catch(() => null);
122
- if (!body?.groupType || !body?.group) return c.json({ error: "groupType and group required" }, 400);
123
- drafts.setGroup(c.get("sid"), body.groupType, body.group);
124
- return c.json({ ok: true });
212
+ if (typeof body?.callId !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(body.callId)) return c.json({ error: "callId required" }, 422);
213
+ const key = draftKey(c);
214
+ let ops;
215
+ let result = {};
216
+ if (tool === "apply_ops") {
217
+ const raw = body.args?.ops;
218
+ if (!Array.isArray(raw) || raw.length === 0) return c.json({
219
+ ok: false,
220
+ reason: "apply_ops takes {ops: [op, ...]}"
221
+ });
222
+ ops = raw;
223
+ } else {
224
+ const seq = drafts.seq(key);
225
+ const expected = body.args?.expected_seq;
226
+ if (typeof expected === "number" && expected !== seq) return c.json({
227
+ ok: false,
228
+ reason: "STALE_READ",
229
+ seq
230
+ }, 409);
231
+ let inputs;
232
+ try {
233
+ inputs = await engineInputs(parseLang(c));
234
+ } catch (err) {
235
+ return c.json({
236
+ ok: false,
237
+ reason: err.message
238
+ });
239
+ }
240
+ const out = applyEditorTool(tool, body.args, {
241
+ ...inputs,
242
+ doc: await documentFor(key),
243
+ sanitizeRichText: await loadRichTextSanitizer(),
244
+ images: "trusted"
245
+ });
246
+ if (!out.ok) return c.json({
247
+ ok: false,
248
+ reason: out.reason
249
+ });
250
+ ops = out.ops;
251
+ result = out.result;
252
+ }
253
+ const clientId = `mcp:${c.get("sid")}:${body.callId}`;
254
+ const batches = [];
255
+ for (const [n, chunk] of chunkOps(ops).entries()) {
256
+ const batch = OpBatchBodySchema.safeParse({
257
+ clientId,
258
+ batchNo: n + 1,
259
+ batchId: crypto.randomUUID(),
260
+ ops: chunk
261
+ });
262
+ if (!batch.success) {
263
+ const issue = batch.error.issues[0];
264
+ return c.json({
265
+ ok: false,
266
+ reason: `invalid ops at ${issue.path.join(".")}: ${issue.message}`
267
+ });
268
+ }
269
+ batches.push(batch.data);
270
+ }
271
+ for (const [n, batch] of batches.entries()) {
272
+ const applied = drafts.apply(key, batch, c.get("userId"), await diskPrior(batch.ops));
273
+ if (!applied.ok) return c.json({
274
+ error: applied.error,
275
+ seq: drafts.seq(key),
276
+ applied: n
277
+ }, applied.status);
278
+ }
279
+ return c.json({
280
+ ok: true,
281
+ result,
282
+ seq: drafts.seq(key)
283
+ });
284
+ });
285
+ app.post(`${PREFIX}/draft/touch`, editorAuth, (c) => c.body(null, 204));
286
+ app.delete(`${PREFIX}/draft`, publishAuth, (c) => {
287
+ const key = draftKey(c);
288
+ if (!drafts.hasUnpublishedEdits(key)) return c.json({ error: "No unpublished changes" }, 404);
289
+ return c.json({
290
+ ok: true,
291
+ seq: drafts.reset(key, "discard")
292
+ });
125
293
  });
126
- app.post(`${PREFIX}/publish`, editorAuth, async (c) => {
127
- drafts.clear(c.get("sid"));
294
+ app.post(`${PREFIX}/publish`, publishAuth, (c) => {
295
+ const key = draftKey(c);
296
+ if (!drafts.hasUnpublishedEdits(key)) return c.json({ error: "No unpublished changes" }, 404);
297
+ drafts.reset(key, "publish");
128
298
  return c.json({
129
299
  ok: true,
130
300
  published: true
@@ -167,15 +337,19 @@ function registerEditorRoutes(app, configDir, auth, store, opts) {
167
337
  return c.json({ ok: true });
168
338
  });
169
339
  app.get(`${PREFIX}/settings`, editorAuthOrPreview, async (c) => {
170
- return c.json(drafts.getSettings(c.get("sid")) ?? await liveSettings());
340
+ const key = draftKey(c);
341
+ c.header("X-Draft-Seq", String(drafts.seq(key)));
342
+ return c.json(drafts.settings(key) ?? await liveSettings());
171
343
  });
172
344
  app.get(`${PREFIX}/template`, editorAuthOrPreview, async (c) => {
173
345
  const id = c.req.query("templateId");
174
346
  if (invalidTemplateId(id)) return c.json({ error: "invalid or missing templateId" }, 400);
175
- const draft = drafts.getTemplate(c.get("sid"), id);
176
- const template = draft ? { ...draft } : await diskTemplate(id);
347
+ const key = draftKey(c);
348
+ c.header("X-Draft-Seq", String(drafts.seq(key)));
349
+ const draft = drafts.template(key, id);
350
+ const template = draft === void 0 ? await diskTemplate(id) : draft && { ...draft };
177
351
  if (!template) return c.json({ error: "Template not found" }, 404);
178
- template.section_groups = await sectionGroupsFor(c.get("sid"));
352
+ template.section_groups = await sectionGroupsFor(key);
179
353
  return c.json(template);
180
354
  });
181
355
  app.get(`${PREFIX}/templates`, editorAuthOrPreview, async (c) => {
package/dist/gateway.mjs CHANGED
@@ -27,7 +27,8 @@ async function startGateway(opts) {
27
27
  app.all("*", createProxy(`http://localhost:${opts.themePort}`));
28
28
  const server = serve({
29
29
  fetch: app.fetch,
30
- port: opts.port
30
+ port: opts.port,
31
+ hostname: "127.0.0.1"
31
32
  });
32
33
  const watcher = opts.watch === false ? void 0 : watchThemeConfig(themeSrcDir, configDir, { preset: opts.preset });
33
34
  return {
package/dist/launch.mjs CHANGED
@@ -46,7 +46,7 @@ const child = spawn(cmd, args, {
46
46
  stdio: "inherit",
47
47
  env: {
48
48
  ...process.env,
49
- ORCHESTRATOR_URL: `http://localhost:${port}`,
49
+ ORCHESTRATOR_URL: `http://127.0.0.1:${port}`,
50
50
  STORE_SLUG: slug,
51
51
  INSTALL_ID: install,
52
52
  ...preset ? { THEME_PRESET: preset } : {},
@@ -78,7 +78,7 @@ if (preset) console.log(` preset editing '${preset}' — Save writes back
78
78
  if (tunnel) console.log(` public ${tunnel.url}`);
79
79
  console.log("");
80
80
  console.log(` preview origin → ${origin}`);
81
- console.log(tunnel ? " ^ paste into the hosted admin theme-editor: actions menu → \"Local dev preview\"." : " ^ localhost-only; add --tunnel to drive a hosted admin.");
81
+ console.log(tunnel ? " ^ `shop-on theme dev` opens the hosted theme editor on this URL." : " ^ localhost-only; `shop-on theme dev` drives a hosted admin.");
82
82
  console.log(" Ctrl-C to stop both.");
83
83
  let shuttingDown = false;
84
84
  async function shutdown(code) {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { watch } from "node:fs";
3
3
  import path from "node:path";
4
- import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { copyFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { currentPresetHandle } from "@shop-on/theme-contract/preset-listing";
6
6
  import { overlayPresetListing } from "@shop-on/theme-contract/preset-materializer";
7
7
  //#region src/materialize.ts
@@ -13,6 +13,8 @@ const JSON_DIRS = [
13
13
  "layouts",
14
14
  "locales"
15
15
  ];
16
+ const AI_DIR = "ai";
17
+ const isWatched = (file) => file.endsWith(".json") || file.endsWith(".md");
16
18
  async function copyJsonDir(srcDir, destDir) {
17
19
  let files;
18
20
  try {
@@ -44,6 +46,14 @@ async function resolveActivePreset(themeSrcDir, presetOverride) {
44
46
  async function materializeThemeConfig(themeSrcDir, configDir, presetOverride) {
45
47
  await mkdir(configDir, { recursive: true });
46
48
  for (const dir of JSON_DIRS) await copyJsonDir(path.join(themeSrcDir, dir), path.join(configDir, dir));
49
+ await rm(path.join(configDir, AI_DIR), {
50
+ recursive: true,
51
+ force: true
52
+ });
53
+ if (await dirExists(path.join(themeSrcDir, AI_DIR))) await cp(path.join(themeSrcDir, AI_DIR), path.join(configDir, AI_DIR), {
54
+ recursive: true,
55
+ filter: async (src) => (await stat(src)).isDirectory() || isWatched(src)
56
+ });
47
57
  try {
48
58
  await copyFile(path.join(themeSrcDir, "config", "settings-schema.json"), path.join(configDir, "settings-schema.json"));
49
59
  } catch {}
@@ -63,7 +73,7 @@ function watchThemeConfig(themeSrcDir, configDir, opts = {}) {
63
73
  const onError = opts.onError ?? ((err) => console.warn("[theme-dev-host] re-materialize failed:", err));
64
74
  let timer;
65
75
  return watch(themeSrcDir, { recursive: true }, (_event, filename) => {
66
- if (!filename || !filename.toString().endsWith(".json")) return;
76
+ if (!filename || !isWatched(filename.toString())) return;
67
77
  clearTimeout(timer);
68
78
  timer = setTimeout(() => {
69
79
  materializeThemeConfig(themeSrcDir, configDir, opts.preset).catch(onError);
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { RICHTEXT_SANITIZE_CONFIG, registerRichTextHooks } from "@shop-on/theme-contract/editor-tools";
3
+ //#region src/richtext.ts
4
+ let loading;
5
+ function loadRichTextSanitizer() {
6
+ loading ??= import("isomorphic-dompurify").then(({ default: purify }) => {
7
+ registerRichTextHooks(purify);
8
+ return (html) => purify.sanitize(html, RICHTEXT_SANITIZE_CONFIG);
9
+ }, (err) => {
10
+ loading = void 0;
11
+ throw err;
12
+ });
13
+ return loading;
14
+ }
15
+ //#endregion
16
+ export { loadRichTextSanitizer };
package/dist/tunnel.mjs CHANGED
@@ -7,7 +7,7 @@ const PROVIDERS = {
7
7
  "cloudflared",
8
8
  "tunnel",
9
9
  "--url",
10
- `http://localhost:${port}`
10
+ `http://127.0.0.1:${port}`
11
11
  ],
12
12
  urlRe: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
13
13
  signup: false
@@ -16,7 +16,7 @@ const PROVIDERS = {
16
16
  argv: (port) => [
17
17
  "ngrok",
18
18
  "http",
19
- String(port),
19
+ `127.0.0.1:${port}`,
20
20
  "--log",
21
21
  "stdout"
22
22
  ],
@@ -28,7 +28,7 @@ const PROVIDERS = {
28
28
  "zrok",
29
29
  "share",
30
30
  "public",
31
- `http://localhost:${port}`
31
+ `http://127.0.0.1:${port}`
32
32
  ],
33
33
  urlRe: /https:\/\/[a-z0-9-]+\.share\.zrok\.io/,
34
34
  signup: true
package/dist/verify.mjs CHANGED
@@ -25,7 +25,7 @@ function createEditorVerifier(pem) {
25
25
  issuer: "SPN"
26
26
  });
27
27
  const claims = payload;
28
- if (!Array.isArray(claims.scp) || !claims.scp.includes("editor")) throw new EditorAuthError("wrong scope");
28
+ if (!Array.isArray(claims.scp)) throw new EditorAuthError("wrong scope");
29
29
  if (!claims.sid || !claims.shop_handle || !claims.install_id) throw new EditorAuthError("missing claims");
30
30
  return claims;
31
31
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shop-on/theme-dev-host",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Local theme-editor dev server: serves the editor contract + proxies a theme's dev server behind one origin, so a theme runs locally and previews in a real admin theme-editor. Nucleus of the future theme CLI.",
5
5
  "license": "SEE LICENSE IN dist/LICENSE.md",
6
6
  "bin": {
@@ -14,8 +14,9 @@
14
14
  "dependencies": {
15
15
  "@hono/node-server": "^2.1.5",
16
16
  "hono": "^4.13.13",
17
+ "isomorphic-dompurify": "4.6.0",
17
18
  "jose": "^6.2.13",
18
- "@shop-on/theme-contract": "0.4.0"
19
+ "@shop-on/theme-contract": "0.6.0"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/node": "26.6.5",