@shop-on/theme-dev-host 0.5.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 +4 -4
- package/dist/auth.mjs +41 -26
- package/dist/drafts.mjs +163 -38
- package/dist/editor-app.mjs +208 -34
- package/dist/gateway.mjs +2 -1
- package/dist/launch.mjs +2 -2
- package/dist/materialize.mjs +12 -2
- package/dist/richtext.mjs +16 -0
- package/dist/tunnel.mjs +3 -3
- package/dist/verify.mjs +1 -1
- package/package.json +3 -2
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.
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
templates: {},
|
|
14
|
-
groups: {}
|
|
15
|
-
};
|
|
16
|
-
sessions.set(sid, s);
|
|
23
|
+
return d;
|
|
17
24
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
169
|
+
export { createDraftStore };
|
package/dist/editor-app.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
50
|
-
const groups =
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
...
|
|
114
|
+
...draftIndex === void 0 ? await diskTemplate("index") : draftIndex
|
|
81
115
|
};
|
|
82
|
-
initialTemplateData.section_groups = await sectionGroupsFor(
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
drafts.
|
|
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
|
-
|
|
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 (
|
|
123
|
-
|
|
124
|
-
|
|
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`,
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
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(
|
|
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://
|
|
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 ? " ^
|
|
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) {
|
package/dist/materialize.mjs
CHANGED
|
@@ -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()
|
|
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://
|
|
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
|
-
|
|
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://
|
|
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)
|
|
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.
|
|
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.
|
|
19
|
+
"@shop-on/theme-contract": "0.6.0"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@types/node": "26.6.5",
|