@shop-on/theme-dev-host 0.0.0-stage → 0.1.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 +76 -2
- package/certs/jwtRS256.dev.pub +9 -0
- package/certs/jwtRS256.prod.pub +9 -0
- package/certs/jwtRS256.staging.pub +9 -0
- package/dist/LICENSE.md +39 -0
- package/dist/auth.mjs +60 -0
- package/dist/drafts.mjs +44 -0
- package/dist/editor-app.mjs +228 -0
- package/dist/gateway.mjs +44 -0
- package/dist/keys.mjs +20 -0
- package/dist/launch.mjs +99 -0
- package/dist/locale.mjs +103 -0
- package/dist/materialize.mjs +93 -0
- package/dist/proxy.mjs +35 -0
- package/dist/save.mjs +54 -0
- package/dist/tunnel.mjs +93 -0
- package/dist/verify.mjs +38 -0
- package/package.json +29 -3
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shop-on/theme-dev-host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A local theme-editor dev server. It serves the **editor contract** (editor-loader,
|
|
4
|
+
draft read/write, template/settings) over a theme's local `src/`, and
|
|
5
|
+
reverse-proxies everything else to the theme's own dev server — behind **one
|
|
6
|
+
origin** a real admin theme-editor can bind to. So a theme runs and previews
|
|
7
|
+
locally while the platform spends ~zero compute (it never boots a storefront,
|
|
8
|
+
allocates a port, or stores an in-progress theme).
|
|
9
|
+
|
|
10
|
+
Self-contained: no Redis, no orchestrator dependency. This is the nucleus of the
|
|
11
|
+
future `theme` CLI (`theme dev`).
|
|
12
|
+
|
|
13
|
+
See **[docs/theme-dev-host.md](../../docs/theme-dev-host.md)** for the architecture.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# One command — starts the theme's dev server AND the gateway, wired together:
|
|
19
|
+
pnpm -C themes/care theme:editor
|
|
20
|
+
|
|
21
|
+
# gateway http://localhost:4101 (open this; ?preview=<sid> shows drafts)
|
|
22
|
+
# theme dev http://localhost:3002
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The gateway port is the single origin for both the editor API and the storefront
|
|
26
|
+
render. `Ctrl-C` stops both.
|
|
27
|
+
|
|
28
|
+
Each theme depends on `@shop-on/theme-dev-host` (`workspace:*`) and exposes
|
|
29
|
+
`theme:editor` → the `theme-dev-host` bin, which infers the theme from the directory
|
|
30
|
+
it runs in — so the command is identical for every theme.
|
|
31
|
+
|
|
32
|
+
## Modes
|
|
33
|
+
|
|
34
|
+
| Command | What it runs |
|
|
35
|
+
| ---------------------------------------------------- | ------------------------------------------------------------- |
|
|
36
|
+
| `pnpm -C themes/<name> theme:editor` | theme dev server **+** gateway (the normal flow) |
|
|
37
|
+
| `pnpm -C packages/theme-dev-host dev --theme <name>` | gateway only (when the theme's `pnpm dev` is already running) |
|
|
38
|
+
|
|
39
|
+
## Flags (`theme:editor`)
|
|
40
|
+
|
|
41
|
+
| Flag | Default | Purpose |
|
|
42
|
+
| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `<name>` (positional) | inferred from the invoking theme dir | override the theme dir under `themes/` |
|
|
44
|
+
| `--port` | `4101` | gateway port (the preview origin) |
|
|
45
|
+
| `--theme-port` | auto (read from the theme's `vite.config`) | the theme's own dev port |
|
|
46
|
+
| `--slug` / `--install` | `<name>` / `devinstall` | the `(shop-handle, install-id)` the gateway store-pins to — **must match the connected install** (install id is in the admin editor URL `/themes/<install-id>/editor`); defaults won't match a real store and 404 `unknown store` |
|
|
47
|
+
| `--preset <handle>` | the theme's `current.preset` | load that preset's settings into the editor and make it the target **Save** writes back to (load-state / save-state); a missing handle opens empty and Save creates it |
|
|
48
|
+
| `--tunnel [provider]` | off | expose the gateway publicly — `cloudflared` (default, no signup), `ngrok`, `zrok` |
|
|
49
|
+
| `--public-url <url>` | — | with `--tunnel manual`: front the gateway with any tunnel you run yourself |
|
|
50
|
+
| `--env` | `dev` | platform whose public key verifies editor tokens (`dev`/`staging`/`prod`) |
|
|
51
|
+
|
|
52
|
+
Editor write routes require a signature-verified RS256 JWT; pair a hosted-prod
|
|
53
|
+
admin with `--env prod` so the right key verifies it.
|
|
54
|
+
|
|
55
|
+
## Saving back to source
|
|
56
|
+
|
|
57
|
+
The editor's draft is in-memory — "publish" clears it, it doesn't persist. To
|
|
58
|
+
commit your work, use the editor's **Save** (writes the current templates +
|
|
59
|
+
section groups + settings into the theme's `src/`) or **Save as preset** (writes
|
|
60
|
+
the current settings under a new named preset). Git is the checkpoint: Save →
|
|
61
|
+
`src/` → commit. `--preset <handle>` picks which slot Save targets, so you can
|
|
62
|
+
load one state, tweak, and Save back to it — or branch to a fresh name with Save
|
|
63
|
+
as preset. Settings are **merged** into the preset map, so sibling presets are
|
|
64
|
+
never clobbered.
|
|
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
|
|
70
|
+
browser on the same machine as the dev server. See
|
|
71
|
+
[docs/theme-dev-host.md](../../docs/theme-dev-host.md).
|
|
72
|
+
|
|
73
|
+
## Test
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pnpm -C packages/theme-dev-host test
|
|
77
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-----BEGIN PUBLIC KEY-----
|
|
2
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxvrVRevpM9Q4DE8mWony
|
|
3
|
+
MYK2AKBKEXA9sxkerBSAJutggePuPn65xwOC4V90+0SUeXUeobe8Ux0QT3QKy244
|
|
4
|
+
oqE3Ykkonwin3+Ws81ZmI4zzo7d1MXXHkuRko64JJT1LHtOYNsxI209177qxxf6J
|
|
5
|
+
tmprUic5tyq8U2TgcbK4qd38mSLI4QFOieDU0r3uqc8U7S4A9NnHzrba9/lUIouJ
|
|
6
|
+
RvU1j0k4wonnk0idAkRuTWLmQRAcNZQnAVQvQ9t9ShLCC0FSnD2quvV3V1r0YnWY
|
|
7
|
+
+sYRy67zVvJ8DgyhIuJdX8cpWPWTh0DUTspHSPMufmeeq058IIkixJz2poDIdtLl
|
|
8
|
+
/wIDAQAB
|
|
9
|
+
-----END PUBLIC KEY-----
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-----BEGIN PUBLIC KEY-----
|
|
2
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqC3UMe4gql3jXPDATrc3
|
|
3
|
+
PeuwutddgDE1sXhuaX/3yjU0Nket0Rbaho82zSU4FgnXXSwtUWCXt+35+b35zLdO
|
|
4
|
+
WzS24TaE/ZlP1LORm91nSKAvQdzPBIP5paA3/JljByLrWxv6fY6U2+ppGiSpO8fM
|
|
5
|
+
n3ZLlIzk99F+hgNg/8cQy8QhaM7Wz6qiFJ93z3Gfbub3XwKmtdGQlCC86Kvh6GB2
|
|
6
|
+
I5IyKZCfPmEAm15JJzh1zEXZZ+n/PgNcTh6xrJiuQM7/hyFpyKaSwn9NAO6Y1oUC
|
|
7
|
+
tmhMLxuqsJGKQ9ci/83x/OU89cqV8Vt3GAN4M4r/B89cekjxvOuX9V/IzGxTtJti
|
|
8
|
+
ZQIDAQAB
|
|
9
|
+
-----END PUBLIC KEY-----
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-----BEGIN PUBLIC KEY-----
|
|
2
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu13KN25kdHurKIzWWOf4
|
|
3
|
+
7nA2zqvEeARCqwOhbBzMjbxCB8wgN0a6LocM0Yqxveq1Hevc3cTnpSSFWhZabv8Q
|
|
4
|
+
08gxgwkq8Ed1SC2KkjWIGxLJFsoUy0w0lHnWVJisHCcs9r9OLIjwWLVs9mDwRFZV
|
|
5
|
+
DWNn75JZOc5B6fffP1KAvfU9S51I+Yy0GZyoegdcRDelJgeQEFL7U3aoNPpzFsaP
|
|
6
|
+
2jeK0eGD37cSxI5XcoDZGoQno7YxFM6vA8wkMZSS7tF8Xo89UHgjpKThC+nhriQe
|
|
7
|
+
OVLHiGYXryQojmNc9/3NpQMhlBIEmI+Joe0siSiiH7VWojwV4Mn0J4BeFOIRxTZ/
|
|
8
|
+
ZQIDAQAB
|
|
9
|
+
-----END PUBLIC KEY-----
|
package/dist/LICENSE.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cody Theme Development License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cody LLC. All rights reserved except as granted below.
|
|
4
|
+
|
|
5
|
+
"The Software" is this package, its source, and its documentation. "The
|
|
6
|
+
Platform" is Cody, the commerce platform operated by Cody LLC.
|
|
7
|
+
|
|
8
|
+
## 1. Grant
|
|
9
|
+
|
|
10
|
+
You may use, copy and modify the Software, and distribute modified copies, solely
|
|
11
|
+
to develop, test, and distribute themes, apps and other extensions that run on
|
|
12
|
+
the Platform.
|
|
13
|
+
|
|
14
|
+
## 2. Restrictions
|
|
15
|
+
|
|
16
|
+
You may not:
|
|
17
|
+
|
|
18
|
+
1. use the Software, or anything derived from it, with any commerce platform,
|
|
19
|
+
storefront or hosting service other than the Platform;
|
|
20
|
+
2. use the Software to build a product that competes with the Platform;
|
|
21
|
+
3. sublicense the Software, or distribute it other than as part of a theme,
|
|
22
|
+
app or extension for the Platform;
|
|
23
|
+
4. remove or alter this license or any copyright notice in the Software.
|
|
24
|
+
|
|
25
|
+
## 3. Your work
|
|
26
|
+
|
|
27
|
+
Themes, apps and extensions you build with the Software are yours. The parts of
|
|
28
|
+
the Software they include stay under this license.
|
|
29
|
+
|
|
30
|
+
## 4. Termination
|
|
31
|
+
|
|
32
|
+
This license ends automatically if you break any of its terms. You must then
|
|
33
|
+
stop using the Software and delete your copies of it.
|
|
34
|
+
|
|
35
|
+
## 5. No warranty
|
|
36
|
+
|
|
37
|
+
The Software is provided "as is", without warranty of any kind. Cody LLC is not
|
|
38
|
+
liable for any claim, damages or other liability arising from the Software or
|
|
39
|
+
its use.
|
package/dist/auth.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { EditorAuthError } from "./verify.mjs";
|
|
3
|
+
//#region src/auth.ts
|
|
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
|
+
function reject(c, reason) {
|
|
6
|
+
console.warn(`theme-dev-host: 401 ${c.req.method} ${c.req.path} — ${reason}`);
|
|
7
|
+
return c.json({ error: reason }, 401);
|
|
8
|
+
}
|
|
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
|
+
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");
|
|
24
|
+
try {
|
|
25
|
+
const fail = await applyJwt(c, header, verify);
|
|
26
|
+
if (fail) return fail;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err instanceof EditorAuthError) return reject(c, err.reason);
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
await next();
|
|
32
|
+
};
|
|
33
|
+
const editorAuthOrPreview = async (c, next) => {
|
|
34
|
+
const header = c.req.header("Authorization");
|
|
35
|
+
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
|
+
}
|
|
43
|
+
await next();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (c.req.method !== "GET") return reject(c, "missing bearer token");
|
|
47
|
+
const sid = c.req.query("sid") ?? c.req.header("X-Editor-Sid") ?? void 0;
|
|
48
|
+
if (!sid || !UUID_RE.test(sid)) return reject(c, "missing or invalid sid");
|
|
49
|
+
c.set("sid", sid);
|
|
50
|
+
c.set("shopHandle", c.req.param("storeSlug") ?? "");
|
|
51
|
+
c.set("installId", c.req.param("installId") ?? "");
|
|
52
|
+
await next();
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
editorAuth,
|
|
56
|
+
editorAuthOrPreview
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { createEditorAuth };
|
package/dist/drafts.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//#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);
|
|
11
|
+
}
|
|
12
|
+
s = {
|
|
13
|
+
templates: {},
|
|
14
|
+
groups: {}
|
|
15
|
+
};
|
|
16
|
+
sessions.set(sid, s);
|
|
17
|
+
}
|
|
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);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { drafts };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { LANG_RE, loadLocale, loadStorefrontLocale, parseLang, readJsonDir, resolveDisplayName, resolveTranslations, unwrapSettings } from "./locale.mjs";
|
|
3
|
+
import { drafts } from "./drafts.mjs";
|
|
4
|
+
import { isValidPresetHandle, writeSettingsPreset, writeThemeSourceFiles } from "./save.mjs";
|
|
5
|
+
import "./auth.mjs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import "hono";
|
|
8
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
9
|
+
import { currentPresetHandle } from "@shop-on/theme-contract/preset-listing";
|
|
10
|
+
import { bodyLimit } from "hono/body-limit";
|
|
11
|
+
//#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
|
+
const PREFIX = "/api/stores/:storeSlug/:installId";
|
|
15
|
+
function registerEditorRoutes(app, configDir, auth, store, opts) {
|
|
16
|
+
const { editorAuth, editorAuthOrPreview } = auth;
|
|
17
|
+
const themeSrcDir = opts?.themeSrcDir;
|
|
18
|
+
const presetOverride = opts?.preset;
|
|
19
|
+
if (store) app.use(`${PREFIX}/*`, async (c, next) => {
|
|
20
|
+
const slug = c.req.param("storeSlug");
|
|
21
|
+
const install = c.req.param("installId");
|
|
22
|
+
if (slug !== store.slug || install !== store.install) {
|
|
23
|
+
console.warn(`theme-dev-host: rejected /api/stores/${slug}/${install}/* — gateway is pinned to ${store.slug}/${store.install}. Restart with --slug ${slug} --install ${install} (the install id is in the admin editor URL).`);
|
|
24
|
+
return c.json({ error: "unknown store" }, 404);
|
|
25
|
+
}
|
|
26
|
+
await next();
|
|
27
|
+
});
|
|
28
|
+
const writeBodyLimit = bodyLimit({
|
|
29
|
+
maxSize: 8388608,
|
|
30
|
+
onError: (c) => c.json({ error: "draft too large" }, 413)
|
|
31
|
+
});
|
|
32
|
+
async function loadSettingsData() {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(await readFile(path.join(configDir, "settings-data.json"), "utf-8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function liveSettings() {
|
|
40
|
+
return unwrapSettings(await loadSettingsData(), presetOverride);
|
|
41
|
+
}
|
|
42
|
+
async function diskTemplate(id) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await readFile(path.join(configDir, "templates", `${id}.json`), "utf-8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function sectionGroupsFor(sid) {
|
|
50
|
+
const groups = drafts.getGroups(sid);
|
|
51
|
+
try {
|
|
52
|
+
for (const file of await readdir(path.join(configDir, "section-groups"))) {
|
|
53
|
+
if (!file.endsWith(".json")) continue;
|
|
54
|
+
const data = JSON.parse(await readFile(path.join(configDir, "section-groups", file), "utf-8"));
|
|
55
|
+
if (data.type && !groups[data.type]) groups[data.type] = {
|
|
56
|
+
sections: data.sections ?? {},
|
|
57
|
+
order: data.order ?? []
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
return groups;
|
|
62
|
+
}
|
|
63
|
+
app.get(`${PREFIX}/editor-loader`, editorAuthOrPreview, async (c) => {
|
|
64
|
+
const sid = c.get("sid");
|
|
65
|
+
const locale = await loadLocale(configDir, parseLang(c));
|
|
66
|
+
const [schemaRaw, sections, blocks, templates, sectionGroups] = await Promise.all([
|
|
67
|
+
readFile(path.join(configDir, "settings-schema.json"), "utf-8").catch(() => null),
|
|
68
|
+
readJsonDir(path.join(configDir, "sections")),
|
|
69
|
+
readJsonDir(path.join(configDir, "blocks")),
|
|
70
|
+
readJsonDir(path.join(configDir, "templates")),
|
|
71
|
+
readJsonDir(path.join(configDir, "section-groups"))
|
|
72
|
+
]);
|
|
73
|
+
const themeSchema = (schemaRaw ? JSON.parse(schemaRaw) : []).map((s) => resolveTranslations(s, locale));
|
|
74
|
+
const settingsData = await loadSettingsData();
|
|
75
|
+
const settings = drafts.getSettings(sid) ?? unwrapSettings(settingsData, presetOverride);
|
|
76
|
+
const activePreset = presetOverride ?? settingsData.current?.preset ?? "default";
|
|
77
|
+
const initialTemplateData = {
|
|
78
|
+
sections: {},
|
|
79
|
+
order: [],
|
|
80
|
+
...drafts.getTemplate(sid, "index") ?? await diskTemplate("index")
|
|
81
|
+
};
|
|
82
|
+
initialTemplateData.section_groups = await sectionGroupsFor(sid);
|
|
83
|
+
let layoutSchema = {};
|
|
84
|
+
try {
|
|
85
|
+
layoutSchema = resolveTranslations(JSON.parse(await readFile(path.join(configDir, "layouts", "main.json"), "utf-8")), locale);
|
|
86
|
+
} catch {}
|
|
87
|
+
return c.json({
|
|
88
|
+
themeSchema,
|
|
89
|
+
sectionSchemas: sections.map(({ id, data }) => ({
|
|
90
|
+
type: id,
|
|
91
|
+
...resolveTranslations(data, locale)
|
|
92
|
+
})),
|
|
93
|
+
blockSchemas: blocks.map(({ id, data }) => ({
|
|
94
|
+
type: id,
|
|
95
|
+
...resolveTranslations(data, locale)
|
|
96
|
+
})),
|
|
97
|
+
templates: templates.map(({ id, data }) => ({
|
|
98
|
+
id,
|
|
99
|
+
name: resolveDisplayName(data, id, locale)
|
|
100
|
+
})),
|
|
101
|
+
sectionGroupDefaults: sectionGroups.map(({ data }) => resolveTranslations(data, locale)),
|
|
102
|
+
initialTemplateData,
|
|
103
|
+
layoutSchema,
|
|
104
|
+
settings,
|
|
105
|
+
activePreset
|
|
106
|
+
});
|
|
107
|
+
});
|
|
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 });
|
|
113
|
+
});
|
|
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 });
|
|
119
|
+
});
|
|
120
|
+
app.put(`${PREFIX}/draft/section-group`, editorAuth, writeBodyLimit, async (c) => {
|
|
121
|
+
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 });
|
|
125
|
+
});
|
|
126
|
+
app.post(`${PREFIX}/publish`, editorAuth, async (c) => {
|
|
127
|
+
drafts.clear(c.get("sid"));
|
|
128
|
+
return c.json({
|
|
129
|
+
ok: true,
|
|
130
|
+
published: true
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
async function saveListingPreset() {
|
|
134
|
+
if (!themeSrcDir) return void 0;
|
|
135
|
+
try {
|
|
136
|
+
if (!(await stat(path.join(themeSrcDir, "listings"))).isDirectory()) return void 0;
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const settingsData = await loadSettingsData();
|
|
141
|
+
return currentPresetHandle(settingsData, presetOverride) ?? "default";
|
|
142
|
+
}
|
|
143
|
+
app.post(`${PREFIX}/save`, editorAuth, writeBodyLimit, async (c) => {
|
|
144
|
+
if (!themeSrcDir) return c.json({ error: "save unavailable (no theme src)" }, 500);
|
|
145
|
+
const body = await c.req.json().catch(() => null);
|
|
146
|
+
if (!body?.files || typeof body.files !== "object") return c.json({ error: "files required" }, 400);
|
|
147
|
+
if (body.settings && !isValidPresetHandle(body.presetHandle)) return c.json({ error: "valid presetHandle required with settings" }, 400);
|
|
148
|
+
try {
|
|
149
|
+
const { written } = await writeThemeSourceFiles(themeSrcDir, body.files, { listingPreset: await saveListingPreset() });
|
|
150
|
+
if (body.settings && isValidPresetHandle(body.presetHandle)) {
|
|
151
|
+
await writeSettingsPreset(themeSrcDir, body.presetHandle, body.settings);
|
|
152
|
+
written.push("config/settings-data.json");
|
|
153
|
+
}
|
|
154
|
+
return c.json({
|
|
155
|
+
ok: true,
|
|
156
|
+
written
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return c.json({ error: err.message }, 400);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
app.post(`${PREFIX}/save-preset`, editorAuth, writeBodyLimit, async (c) => {
|
|
163
|
+
if (!themeSrcDir) return c.json({ error: "save unavailable (no theme src)" }, 500);
|
|
164
|
+
const body = await c.req.json().catch(() => null);
|
|
165
|
+
if (!isValidPresetHandle(body?.handle) || !body?.settings || typeof body.settings !== "object") return c.json({ error: "handle and settings required" }, 400);
|
|
166
|
+
await writeSettingsPreset(themeSrcDir, body.handle, body.settings, { makeCurrent: body.makeCurrent === true });
|
|
167
|
+
return c.json({ ok: true });
|
|
168
|
+
});
|
|
169
|
+
app.get(`${PREFIX}/settings`, editorAuthOrPreview, async (c) => {
|
|
170
|
+
return c.json(drafts.getSettings(c.get("sid")) ?? await liveSettings());
|
|
171
|
+
});
|
|
172
|
+
app.get(`${PREFIX}/template`, editorAuthOrPreview, async (c) => {
|
|
173
|
+
const id = c.req.query("templateId");
|
|
174
|
+
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);
|
|
177
|
+
if (!template) return c.json({ error: "Template not found" }, 404);
|
|
178
|
+
template.section_groups = await sectionGroupsFor(c.get("sid"));
|
|
179
|
+
return c.json(template);
|
|
180
|
+
});
|
|
181
|
+
app.get(`${PREFIX}/templates`, editorAuthOrPreview, async (c) => {
|
|
182
|
+
const locale = await loadLocale(configDir, parseLang(c));
|
|
183
|
+
const items = await readJsonDir(path.join(configDir, "templates"));
|
|
184
|
+
return c.json(items.map(({ id, data }) => ({
|
|
185
|
+
id,
|
|
186
|
+
name: resolveDisplayName(data, id, locale)
|
|
187
|
+
})));
|
|
188
|
+
});
|
|
189
|
+
app.get(`${PREFIX}/sections`, editorAuthOrPreview, async (c) => {
|
|
190
|
+
const locale = await loadLocale(configDir, parseLang(c));
|
|
191
|
+
const items = await readJsonDir(path.join(configDir, "sections"));
|
|
192
|
+
return c.json(items.map(({ id, data }) => ({
|
|
193
|
+
type: id,
|
|
194
|
+
...resolveTranslations(data, locale)
|
|
195
|
+
})));
|
|
196
|
+
});
|
|
197
|
+
app.get(`${PREFIX}/blocks`, editorAuthOrPreview, async (c) => {
|
|
198
|
+
const locale = await loadLocale(configDir, parseLang(c));
|
|
199
|
+
const items = await readJsonDir(path.join(configDir, "blocks"));
|
|
200
|
+
return c.json(items.map(({ id, data }) => ({
|
|
201
|
+
type: id,
|
|
202
|
+
...resolveTranslations(data, locale)
|
|
203
|
+
})));
|
|
204
|
+
});
|
|
205
|
+
app.get(`${PREFIX}/section-groups`, editorAuthOrPreview, async (c) => {
|
|
206
|
+
const locale = await loadLocale(configDir, parseLang(c));
|
|
207
|
+
const items = await readJsonDir(path.join(configDir, "section-groups"));
|
|
208
|
+
return c.json(items.map(({ data }) => resolveTranslations(data, locale)));
|
|
209
|
+
});
|
|
210
|
+
app.get(`${PREFIX}/storefront-strings`, editorAuthOrPreview, async (c) => {
|
|
211
|
+
const lang = c.req.query("lang");
|
|
212
|
+
if (!lang || !LANG_RE.test(lang)) return c.json({ error: "Invalid lang" }, 400);
|
|
213
|
+
return c.json(await loadStorefrontLocale(configDir, lang));
|
|
214
|
+
});
|
|
215
|
+
app.get(`${PREFIX}/layout-schema`, editorAuthOrPreview, async (c) => {
|
|
216
|
+
const layout = c.req.query("layout") ?? "main";
|
|
217
|
+
if (invalidTemplateId(layout)) return c.json({ error: "invalid layout" }, 400);
|
|
218
|
+
const locale = await loadLocale(configDir, parseLang(c));
|
|
219
|
+
try {
|
|
220
|
+
const raw = await readFile(path.join(configDir, "layouts", `${layout}.json`), "utf-8");
|
|
221
|
+
return c.json(resolveTranslations(JSON.parse(raw), locale));
|
|
222
|
+
} catch {
|
|
223
|
+
return c.json({ error: "Layout not found" }, 404);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
export { registerEditorRoutes };
|
package/dist/gateway.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { materializeThemeConfig, watchThemeConfig } from "./materialize.mjs";
|
|
3
|
+
import { createEditorVerifier } from "./verify.mjs";
|
|
4
|
+
import { createEditorAuth } from "./auth.mjs";
|
|
5
|
+
import { registerEditorRoutes } from "./editor-app.mjs";
|
|
6
|
+
import { createProxy } from "./proxy.mjs";
|
|
7
|
+
import { mkdtempSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import { serve } from "@hono/node-server";
|
|
12
|
+
//#region src/gateway.ts
|
|
13
|
+
async function startGateway(opts) {
|
|
14
|
+
const themeSrcDir = path.join(opts.themeDir, "src");
|
|
15
|
+
const storesDir = mkdtempSync(path.join(os.tmpdir(), "theme-dev-host-"));
|
|
16
|
+
const configDir = path.join(storesDir, opts.slug, opts.install, "config");
|
|
17
|
+
await materializeThemeConfig(themeSrcDir, configDir, opts.preset);
|
|
18
|
+
const auth = createEditorAuth(createEditorVerifier(opts.jwtPublicKey));
|
|
19
|
+
const app = new Hono();
|
|
20
|
+
registerEditorRoutes(app, configDir, auth, {
|
|
21
|
+
slug: opts.slug,
|
|
22
|
+
install: opts.install
|
|
23
|
+
}, {
|
|
24
|
+
themeSrcDir,
|
|
25
|
+
preset: opts.preset
|
|
26
|
+
});
|
|
27
|
+
app.all("*", createProxy(`http://localhost:${opts.themePort}`));
|
|
28
|
+
const server = serve({
|
|
29
|
+
fetch: app.fetch,
|
|
30
|
+
port: opts.port
|
|
31
|
+
});
|
|
32
|
+
const watcher = opts.watch === false ? void 0 : watchThemeConfig(themeSrcDir, configDir, { preset: opts.preset });
|
|
33
|
+
return {
|
|
34
|
+
configDir,
|
|
35
|
+
themeSrcDir,
|
|
36
|
+
server,
|
|
37
|
+
close: () => {
|
|
38
|
+
watcher?.close();
|
|
39
|
+
server.close();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { startGateway };
|
package/dist/keys.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
//#region src/keys.ts
|
|
5
|
+
const PLATFORM_ENVS = [
|
|
6
|
+
"dev",
|
|
7
|
+
"staging",
|
|
8
|
+
"prod"
|
|
9
|
+
];
|
|
10
|
+
function isPlatformEnv(v) {
|
|
11
|
+
return PLATFORM_ENVS.includes(v);
|
|
12
|
+
}
|
|
13
|
+
function resolveJwtPublicKey(env) {
|
|
14
|
+
const fromEnv = process.env.JWT_PUBLIC_KEY;
|
|
15
|
+
if (fromEnv) return fromEnv.replaceAll("\\n", "\n");
|
|
16
|
+
const certPath = path.resolve(import.meta.dirname, "../certs", `jwtRS256.${env}.pub`);
|
|
17
|
+
return readFileSync(certPath, "utf-8");
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { PLATFORM_ENVS, isPlatformEnv, resolveJwtPublicKey };
|
package/dist/launch.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startGateway } from "./gateway.mjs";
|
|
3
|
+
import { startTunnel } from "./tunnel.mjs";
|
|
4
|
+
import { isPlatformEnv, resolveJwtPublicKey } from "./keys.mjs";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
//#region src/launch.ts
|
|
9
|
+
function flagValue(name) {
|
|
10
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
11
|
+
if (i < 0) return void 0;
|
|
12
|
+
const v = process.argv[i + 1];
|
|
13
|
+
return v && !v.startsWith("--") ? v : void 0;
|
|
14
|
+
}
|
|
15
|
+
const hasFlag = (name) => process.argv.includes(`--${name}`);
|
|
16
|
+
function detectThemePort(themeDir) {
|
|
17
|
+
try {
|
|
18
|
+
const m = readFileSync(path.join(themeDir, "vite.config.ts"), "utf-8").match(/port:\s*(\d+)/);
|
|
19
|
+
if (m) return Number(m[1]);
|
|
20
|
+
} catch {}
|
|
21
|
+
return 3002;
|
|
22
|
+
}
|
|
23
|
+
const themeDir = path.resolve(flagValue("dir") ?? process.cwd());
|
|
24
|
+
const theme = path.basename(themeDir);
|
|
25
|
+
const port = Number(flagValue("port") ?? "4101");
|
|
26
|
+
const themePort = Number(flagValue("theme-port") ?? detectThemePort(themeDir));
|
|
27
|
+
const slug = flagValue("slug") ?? theme;
|
|
28
|
+
const install = flagValue("install") ?? "devinstall";
|
|
29
|
+
const preset = flagValue("preset");
|
|
30
|
+
const env = flagValue("env") ?? "dev";
|
|
31
|
+
if (!isPlatformEnv(env)) {
|
|
32
|
+
console.error(`--env must be one of dev|staging|prod (got "${env}")`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const jwtPublicKey = resolveJwtPublicKey(env);
|
|
36
|
+
const tunnelProvider = hasFlag("tunnel") ? flagValue("tunnel") ?? "cloudflared" : "none";
|
|
37
|
+
const publicUrl = flagValue("public-url");
|
|
38
|
+
const pm = process.env.npm_execpath;
|
|
39
|
+
const [cmd, args] = !pm ? ["npm", ["run", "dev"]] : /\.[cm]?js$/.test(pm) ? [process.execPath, [
|
|
40
|
+
pm,
|
|
41
|
+
"run",
|
|
42
|
+
"dev"
|
|
43
|
+
]] : [pm, ["run", "dev"]];
|
|
44
|
+
const child = spawn(cmd, args, {
|
|
45
|
+
cwd: themeDir,
|
|
46
|
+
stdio: "inherit",
|
|
47
|
+
env: {
|
|
48
|
+
...process.env,
|
|
49
|
+
ORCHESTRATOR_URL: `http://localhost:${port}`,
|
|
50
|
+
STORE_SLUG: slug,
|
|
51
|
+
INSTALL_ID: install,
|
|
52
|
+
...preset ? { THEME_PRESET: preset } : {},
|
|
53
|
+
...tunnelProvider !== "none" ? { THEME_HMR_HOST: "localhost" } : {}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const gw = await startGateway({
|
|
57
|
+
themeDir,
|
|
58
|
+
port,
|
|
59
|
+
themePort,
|
|
60
|
+
slug,
|
|
61
|
+
install,
|
|
62
|
+
jwtPublicKey,
|
|
63
|
+
preset
|
|
64
|
+
});
|
|
65
|
+
let tunnel = null;
|
|
66
|
+
try {
|
|
67
|
+
tunnel = await startTunnel(tunnelProvider, port, publicUrl);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`tunnel: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
const origin = tunnel?.url ?? `http://localhost:${port}`;
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(`theme:editor — ${theme} (slug=${slug}, install=${install})`);
|
|
74
|
+
console.log(` gateway http://localhost:${port} (editor API + storefront proxy)`);
|
|
75
|
+
console.log(` theme dev http://localhost:${themePort} (starting — first load compiles)`);
|
|
76
|
+
console.log(` editor auth verifying editor tokens against the ${env} platform key`);
|
|
77
|
+
if (preset) console.log(` preset editing '${preset}' — Save writes back to this preset`);
|
|
78
|
+
if (tunnel) console.log(` public ${tunnel.url}`);
|
|
79
|
+
console.log("");
|
|
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.");
|
|
82
|
+
console.log(" Ctrl-C to stop both.");
|
|
83
|
+
let shuttingDown = false;
|
|
84
|
+
async function shutdown(code) {
|
|
85
|
+
if (shuttingDown) return;
|
|
86
|
+
shuttingDown = true;
|
|
87
|
+
child.kill("SIGINT");
|
|
88
|
+
await tunnel?.stop();
|
|
89
|
+
gw.close();
|
|
90
|
+
process.exit(code);
|
|
91
|
+
}
|
|
92
|
+
process.on("SIGINT", () => void shutdown(0));
|
|
93
|
+
process.on("SIGTERM", () => void shutdown(0));
|
|
94
|
+
child.on("exit", (code) => {
|
|
95
|
+
console.log(`\ntheme dev server exited — shutting down gateway.`);
|
|
96
|
+
shutdown(code ?? 0);
|
|
97
|
+
});
|
|
98
|
+
//#endregion
|
|
99
|
+
export {};
|
package/dist/locale.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
4
|
+
//#region src/locale.ts
|
|
5
|
+
async function readJsonDir(dir) {
|
|
6
|
+
let files;
|
|
7
|
+
try {
|
|
8
|
+
files = await readdir(dir);
|
|
9
|
+
} catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
const results = [];
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
if (!file.endsWith(".json")) continue;
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readFile(path.join(dir, file), "utf-8");
|
|
17
|
+
results.push({
|
|
18
|
+
id: file.replace(".json", ""),
|
|
19
|
+
data: JSON.parse(raw)
|
|
20
|
+
});
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
function resolveTranslations(obj, locale) {
|
|
26
|
+
if (typeof obj === "string" && obj.startsWith("t:")) {
|
|
27
|
+
const key = obj.slice(2);
|
|
28
|
+
let cur = locale;
|
|
29
|
+
for (const p of key.split(".")) cur = cur?.[p];
|
|
30
|
+
return typeof cur === "string" ? cur : obj;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(obj)) return obj.map((v) => resolveTranslations(v, locale));
|
|
33
|
+
if (obj && typeof obj === "object") {
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [k, v] of Object.entries(obj)) out[k] = resolveTranslations(v, locale);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
return obj;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a human-readable display name for a config item. Tries `data.name`
|
|
42
|
+
* through the locale, falls back to the id when the name is missing or its
|
|
43
|
+
* `t:*` key isn't in the locale — never leaks a raw `t:*` token to clients.
|
|
44
|
+
*/
|
|
45
|
+
function resolveDisplayName(data, id, locale) {
|
|
46
|
+
const name = data?.name;
|
|
47
|
+
if (typeof name === "string" && name !== "") {
|
|
48
|
+
const resolved = resolveTranslations(name, locale);
|
|
49
|
+
if (typeof resolved === "string" && !resolved.startsWith("t:")) return resolved;
|
|
50
|
+
}
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
async function loadLocale(baseDir, lang) {
|
|
54
|
+
const localeDir = path.join(baseDir, "locales");
|
|
55
|
+
const read = async (file) => JSON.parse(await readFile(path.join(localeDir, file), "utf-8"));
|
|
56
|
+
try {
|
|
57
|
+
return await read(`${lang}.default.schema.json`);
|
|
58
|
+
} catch {
|
|
59
|
+
try {
|
|
60
|
+
const files = (await readdir(localeDir)).filter((f) => f.endsWith(".default.schema.json")).sort();
|
|
61
|
+
if (files[0]) return await read(files[0]);
|
|
62
|
+
} catch {}
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Buyer-facing catalog (`<lang>.default.json` — NOT the `.schema.json` editor
|
|
68
|
+
* strings). Returns the catalog PLUS which shipped locale actually served it:
|
|
69
|
+
* the admin Translations page labels its "default value" column with that
|
|
70
|
+
* language, so the fallback must be visible, not silent.
|
|
71
|
+
*/
|
|
72
|
+
async function loadStorefrontLocale(baseDir, lang) {
|
|
73
|
+
const localeDir = path.join(baseDir, "locales");
|
|
74
|
+
const read = async (file) => JSON.parse(await readFile(path.join(localeDir, file), "utf-8"));
|
|
75
|
+
try {
|
|
76
|
+
return {
|
|
77
|
+
lang,
|
|
78
|
+
messages: await read(`${lang}.default.json`)
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
try {
|
|
82
|
+
const files = (await readdir(localeDir)).filter((f) => f.endsWith(".default.json")).sort();
|
|
83
|
+
if (files[0]) return {
|
|
84
|
+
lang: files[0].replace(".default.json", ""),
|
|
85
|
+
messages: await read(files[0])
|
|
86
|
+
};
|
|
87
|
+
} catch {}
|
|
88
|
+
return {
|
|
89
|
+
lang,
|
|
90
|
+
messages: {}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const LANG_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
95
|
+
function parseLang(c) {
|
|
96
|
+
const raw = c.req.header("Accept-Language")?.split(",")[0]?.trim();
|
|
97
|
+
return raw && LANG_RE.test(raw) ? raw : "mn";
|
|
98
|
+
}
|
|
99
|
+
function unwrapSettings(data, preset) {
|
|
100
|
+
return data.presets ? data.presets[preset ?? data.current?.preset]?.settings ?? {} : data;
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
103
|
+
export { LANG_RE, loadLocale, loadStorefrontLocale, parseLang, readJsonDir, resolveDisplayName, resolveTranslations, unwrapSettings };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { LISTING_DIRS, currentPresetHandle, mergePresetJson } from "@shop-on/theme-contract/preset-listing";
|
|
6
|
+
//#region src/materialize.ts
|
|
7
|
+
const JSON_DIRS = [
|
|
8
|
+
"sections",
|
|
9
|
+
"blocks",
|
|
10
|
+
"templates",
|
|
11
|
+
"section-groups",
|
|
12
|
+
"layouts",
|
|
13
|
+
"locales"
|
|
14
|
+
];
|
|
15
|
+
async function copyJsonDir(srcDir, destDir) {
|
|
16
|
+
let files;
|
|
17
|
+
try {
|
|
18
|
+
files = await readdir(srcDir);
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await mkdir(destDir, { recursive: true });
|
|
23
|
+
const wanted = new Set(files.filter((f) => f.endsWith(".json") && !f.startsWith("._")));
|
|
24
|
+
try {
|
|
25
|
+
for (const existing of await readdir(destDir)) if (existing.endsWith(".json") && !wanted.has(existing)) await rm(path.join(destDir, existing), { force: true });
|
|
26
|
+
} catch {}
|
|
27
|
+
for (const file of wanted) await copyFile(path.join(srcDir, file), path.join(destDir, file));
|
|
28
|
+
}
|
|
29
|
+
async function dirExists(dir) {
|
|
30
|
+
try {
|
|
31
|
+
return (await stat(dir)).isDirectory();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function resolveActivePreset(themeSrcDir, presetOverride) {
|
|
37
|
+
let data = null;
|
|
38
|
+
try {
|
|
39
|
+
data = JSON.parse(await readFile(path.join(themeSrcDir, "config", "settings-data.json"), "utf-8"));
|
|
40
|
+
} catch {}
|
|
41
|
+
return currentPresetHandle(data, presetOverride);
|
|
42
|
+
}
|
|
43
|
+
async function overlayListingDir(listingDir, targetDir) {
|
|
44
|
+
let files;
|
|
45
|
+
try {
|
|
46
|
+
files = await readdir(listingDir);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await mkdir(targetDir, { recursive: true });
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
if (!file.endsWith(".json") || file.startsWith("._")) continue;
|
|
53
|
+
const override = JSON.parse(await readFile(path.join(listingDir, file), "utf-8"));
|
|
54
|
+
const targetPath = path.join(targetDir, file);
|
|
55
|
+
let base = null;
|
|
56
|
+
try {
|
|
57
|
+
base = JSON.parse(await readFile(targetPath, "utf-8"));
|
|
58
|
+
} catch {}
|
|
59
|
+
const output = base === null ? override : mergePresetJson(base, override);
|
|
60
|
+
await writeFile(targetPath, `${JSON.stringify(output, null, 2)}\n`, "utf-8");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function materializeThemeConfig(themeSrcDir, configDir, presetOverride) {
|
|
64
|
+
await mkdir(configDir, { recursive: true });
|
|
65
|
+
for (const dir of JSON_DIRS) await copyJsonDir(path.join(themeSrcDir, dir), path.join(configDir, dir));
|
|
66
|
+
try {
|
|
67
|
+
await copyFile(path.join(themeSrcDir, "config", "settings-schema.json"), path.join(configDir, "settings-schema.json"));
|
|
68
|
+
} catch {}
|
|
69
|
+
const settingsDataPath = path.join(configDir, "settings-data.json");
|
|
70
|
+
try {
|
|
71
|
+
await copyFile(path.join(themeSrcDir, "config", "settings-data.json"), settingsDataPath);
|
|
72
|
+
} catch {
|
|
73
|
+
await writeFile(settingsDataPath, "{}\n", "utf-8");
|
|
74
|
+
}
|
|
75
|
+
const activePreset = await resolveActivePreset(themeSrcDir, presetOverride);
|
|
76
|
+
if (activePreset) {
|
|
77
|
+
const listingDir = path.join(themeSrcDir, "listings", activePreset);
|
|
78
|
+
if (await dirExists(listingDir)) for (const dir of LISTING_DIRS) await overlayListingDir(path.join(listingDir, dir), path.join(configDir, dir));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function watchThemeConfig(themeSrcDir, configDir, opts = {}) {
|
|
82
|
+
const onError = opts.onError ?? ((err) => console.warn("[theme-dev-host] re-materialize failed:", err));
|
|
83
|
+
let timer;
|
|
84
|
+
return watch(themeSrcDir, { recursive: true }, (_event, filename) => {
|
|
85
|
+
if (!filename || !filename.toString().endsWith(".json")) return;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
timer = setTimeout(() => {
|
|
88
|
+
materializeThemeConfig(themeSrcDir, configDir, opts.preset).catch(onError);
|
|
89
|
+
}, 150);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export { materializeThemeConfig, watchThemeConfig };
|
package/dist/proxy.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//#region src/proxy.ts
|
|
3
|
+
function createProxy(target) {
|
|
4
|
+
const targetOrigin = new URL(target).origin;
|
|
5
|
+
return async (c) => {
|
|
6
|
+
const incoming = new URL(c.req.url);
|
|
7
|
+
if (incoming.pathname.startsWith("//")) return c.text("bad request", 400);
|
|
8
|
+
const url = new URL(incoming.pathname + incoming.search, target);
|
|
9
|
+
if (url.origin !== targetOrigin) return c.text("bad request", 400);
|
|
10
|
+
const headers = new Headers(c.req.raw.headers);
|
|
11
|
+
headers.delete("host");
|
|
12
|
+
headers.delete("authorization");
|
|
13
|
+
const method = c.req.method;
|
|
14
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
15
|
+
let res;
|
|
16
|
+
try {
|
|
17
|
+
res = await fetch(url, {
|
|
18
|
+
method,
|
|
19
|
+
headers,
|
|
20
|
+
body: hasBody ? c.req.raw.body : void 0,
|
|
21
|
+
redirect: "manual",
|
|
22
|
+
...hasBody ? { duplex: "half" } : {}
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
return c.text(`theme-dev-host: theme dev server not reachable at ${target} — is \`pnpm dev\` running for the theme?`, 502);
|
|
26
|
+
}
|
|
27
|
+
return new Response(res.body, {
|
|
28
|
+
status: res.status,
|
|
29
|
+
statusText: res.statusText,
|
|
30
|
+
headers: res.headers
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
export { createProxy };
|
package/dist/save.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { PRESET_HANDLE_RE } from "@shop-on/theme-contract/preset-listing";
|
|
5
|
+
//#region src/save.ts
|
|
6
|
+
function serialize(data) {
|
|
7
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
8
|
+
}
|
|
9
|
+
const WRITABLE_DIRS = /* @__PURE__ */ new Set(["templates", "section-groups"]);
|
|
10
|
+
const FILE_RE = /^[A-Za-z0-9_.-]+\.json$/;
|
|
11
|
+
function isWritableThemePath(rel) {
|
|
12
|
+
if (typeof rel !== "string" || rel.includes("\\")) return false;
|
|
13
|
+
const parts = rel.split("/");
|
|
14
|
+
if (parts.length !== 2) return false;
|
|
15
|
+
const [dir, file] = parts;
|
|
16
|
+
if (!dir || !file) return false;
|
|
17
|
+
return WRITABLE_DIRS.has(dir) && FILE_RE.test(file);
|
|
18
|
+
}
|
|
19
|
+
function isValidPresetHandle(handle) {
|
|
20
|
+
return typeof handle === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(handle);
|
|
21
|
+
}
|
|
22
|
+
async function writeThemeSourceFiles(themeSrcDir, files, opts = {}) {
|
|
23
|
+
const root = path.resolve(themeSrcDir);
|
|
24
|
+
const listingPreset = opts.listingPreset;
|
|
25
|
+
if (listingPreset !== void 0 && !PRESET_HANDLE_RE.test(listingPreset)) throw new Error(`invalid listing preset handle: ${listingPreset}`);
|
|
26
|
+
const written = [];
|
|
27
|
+
for (const [rel, data] of Object.entries(files)) {
|
|
28
|
+
if (!isWritableThemePath(rel)) throw new Error(`refusing to write disallowed path: ${rel}`);
|
|
29
|
+
const destRel = listingPreset ? `listings/${listingPreset}/${rel}` : rel;
|
|
30
|
+
const dest = path.resolve(root, destRel);
|
|
31
|
+
if (dest !== root && !dest.startsWith(root + path.sep)) throw new Error(`refusing to write outside theme src: ${rel}`);
|
|
32
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
33
|
+
await writeFile(dest, serialize(data), "utf-8");
|
|
34
|
+
written.push(destRel);
|
|
35
|
+
}
|
|
36
|
+
return { written };
|
|
37
|
+
}
|
|
38
|
+
async function writeSettingsPreset(themeSrcDir, handle, settings, opts = {}) {
|
|
39
|
+
const file = path.join(themeSrcDir, "config", "settings-data.json");
|
|
40
|
+
let data = {};
|
|
41
|
+
try {
|
|
42
|
+
data = JSON.parse(await readFile(file, "utf-8"));
|
|
43
|
+
} catch {}
|
|
44
|
+
const presets = { ...data.presets };
|
|
45
|
+
presets[handle] = { settings };
|
|
46
|
+
const next = {
|
|
47
|
+
current: { preset: opts.makeCurrent || !data.current ? handle : data.current.preset },
|
|
48
|
+
presets
|
|
49
|
+
};
|
|
50
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
51
|
+
await writeFile(file, serialize(next), "utf-8");
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { isValidPresetHandle, isWritableThemePath, writeSettingsPreset, writeThemeSourceFiles };
|
package/dist/tunnel.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
//#region src/tunnel.ts
|
|
4
|
+
const PROVIDERS = {
|
|
5
|
+
cloudflared: {
|
|
6
|
+
argv: (port) => [
|
|
7
|
+
"cloudflared",
|
|
8
|
+
"tunnel",
|
|
9
|
+
"--url",
|
|
10
|
+
`http://localhost:${port}`
|
|
11
|
+
],
|
|
12
|
+
urlRe: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
|
13
|
+
signup: false
|
|
14
|
+
},
|
|
15
|
+
ngrok: {
|
|
16
|
+
argv: (port) => [
|
|
17
|
+
"ngrok",
|
|
18
|
+
"http",
|
|
19
|
+
String(port),
|
|
20
|
+
"--log",
|
|
21
|
+
"stdout"
|
|
22
|
+
],
|
|
23
|
+
urlRe: /https:\/\/[a-z0-9-]+\.ngrok[-a-z0-9.]*\.(app|dev|io)/,
|
|
24
|
+
signup: true
|
|
25
|
+
},
|
|
26
|
+
zrok: {
|
|
27
|
+
argv: (port) => [
|
|
28
|
+
"zrok",
|
|
29
|
+
"share",
|
|
30
|
+
"public",
|
|
31
|
+
`http://localhost:${port}`
|
|
32
|
+
],
|
|
33
|
+
urlRe: /https:\/\/[a-z0-9-]+\.share\.zrok\.io/,
|
|
34
|
+
signup: true
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function spawnTunnel(name, argv, urlRe) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const proc = spawn(argv[0], argv.slice(1), { stdio: [
|
|
40
|
+
"ignore",
|
|
41
|
+
"pipe",
|
|
42
|
+
"pipe"
|
|
43
|
+
] });
|
|
44
|
+
let settled = false;
|
|
45
|
+
const scan = (buf) => {
|
|
46
|
+
const m = buf.toString().match(urlRe);
|
|
47
|
+
if (m && !settled) {
|
|
48
|
+
settled = true;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve({
|
|
51
|
+
url: m[0],
|
|
52
|
+
stop: async () => {
|
|
53
|
+
proc.kill();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
proc.stdout.on("data", scan);
|
|
59
|
+
proc.stderr.on("data", scan);
|
|
60
|
+
proc.on("error", (err) => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
reject(/* @__PURE__ */ new Error(`${name} not found on PATH — install it, or use \`--tunnel manual --public-url <url>\`. (${err.message})`));
|
|
65
|
+
});
|
|
66
|
+
proc.on("exit", (code) => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
reject(/* @__PURE__ */ new Error(`${name} exited (code ${code}) before printing a public URL`));
|
|
71
|
+
});
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
settled = true;
|
|
75
|
+
proc.kill();
|
|
76
|
+
reject(/* @__PURE__ */ new Error(`${name} timed out before printing a public URL`));
|
|
77
|
+
}, 3e4);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function startTunnel(provider, port, publicUrl) {
|
|
81
|
+
if (provider === "none") return null;
|
|
82
|
+
if (provider === "manual") {
|
|
83
|
+
if (!publicUrl) throw new Error("`--tunnel manual` requires `--public-url <url>`");
|
|
84
|
+
return {
|
|
85
|
+
url: publicUrl,
|
|
86
|
+
stop: async () => {}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const p = PROVIDERS[provider];
|
|
90
|
+
return spawnTunnel(provider, p.argv(port), p.urlRe);
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export { startTunnel };
|
package/dist/verify.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { importSPKI, jwtVerify } from "jose";
|
|
3
|
+
//#region src/verify.ts
|
|
4
|
+
var EditorAuthError = class extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason) {
|
|
7
|
+
super(reason);
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.name = "EditorAuthError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function createEditorVerifier(pem) {
|
|
13
|
+
let keyPromise;
|
|
14
|
+
return async (token) => {
|
|
15
|
+
if (!pem) throw new EditorAuthError("JWT public key not configured");
|
|
16
|
+
let key;
|
|
17
|
+
try {
|
|
18
|
+
key = await (keyPromise ??= importSPKI(pem, "RS256"));
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw new EditorAuthError(err.message);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const { payload } = await jwtVerify(token, key, {
|
|
24
|
+
algorithms: ["RS256"],
|
|
25
|
+
issuer: "SPN"
|
|
26
|
+
});
|
|
27
|
+
const claims = payload;
|
|
28
|
+
if (!Array.isArray(claims.scp) || !claims.scp.includes("editor")) throw new EditorAuthError("wrong scope");
|
|
29
|
+
if (!claims.sid || !claims.shop_handle || !claims.install_id) throw new EditorAuthError("missing claims");
|
|
30
|
+
return claims;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err instanceof EditorAuthError) throw err;
|
|
33
|
+
throw new EditorAuthError(err.message);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
export { EditorAuthError, createEditorVerifier };
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shop-on/theme-dev-host",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "SEE LICENSE IN dist/LICENSE.md",
|
|
6
|
+
"bin": {
|
|
7
|
+
"theme-dev-host": "./dist/launch.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"certs"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@hono/node-server": "^2.1.3",
|
|
16
|
+
"hono": "^4.13.12",
|
|
17
|
+
"jose": "^6.2.12",
|
|
18
|
+
"@shop-on/theme-contract": "0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "26.6.3",
|
|
22
|
+
"tsx": "^4.23.15",
|
|
23
|
+
"typescript": "7.0.2",
|
|
24
|
+
"vitest": "5.0.3"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "tsx src/index.ts",
|
|
28
|
+
"launch": "tsx src/launch.ts",
|
|
29
|
+
"check-types": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
}
|
|
6
32
|
}
|