@osimart/create-storefront 1.0.1 → 1.0.2

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/THEME_DEV_API.md CHANGED
@@ -1,80 +1,69 @@
1
- # Theme dev API (backend contract for `osimart-theme`)
1
+ # Theme dev API (what `osimart-theme` calls)
2
2
 
3
- All under `theme-dev/` on the store's API host. The host picks the store, as for every
4
- storefront call.
3
+ Implemented in the backend's `builder/theme_dev.py`. Routes:
5
4
 
6
- **Auth:** `Authorization: ThemeToken <token>`. The `ThemeAccessToken` belongs to one store
7
- (hashed, revocable, created in the dashboard). Anything else gets `401`.
5
+ - **Store host** (what the CLI uses): `builder/theme_dev.py` (its `urlpatterns`), included in the storefront
6
+ URL config as `store/apis/theme-dev/`. The CLI calls `<origin of NUXT_PUBLIC_STORE_API_BASE>/store/apis/theme-dev/`.
7
+ - **Dashboard host**: `builder/urls.py` registers the same viewset as `online-store/apis/theme-dev/`,
8
+ plus `online-store/apis/theme-access-tokens/` for the dashboard's Theme access page.
8
9
 
9
- `<theme>` is either a theme id, `live` (the published theme) or `draft`.
10
+ The store comes from the token, never the host. `OSIMART_THEME_DEV_URL` overrides the CLI's address.
10
11
 
11
- ## Existing (from the theme access work)
12
+ **Auth:** `Authorization: ThemeToken osit_…`. A `ThemeAccessToken` belongs to one store; only
13
+ its SHA-256 is stored. Issue one with `python manage.py create_theme_token --store <uuid>` or
14
+ `POST theme-access-tokens/ {store, name}` (dashboard auth; the response has `token` once).
15
+ `DELETE theme-access-tokens/<id>/` revokes it.
12
16
 
13
- | Method | Path | Used by |
14
- |---|---|---|
15
- | GET | `theme-dev/schemas/` | `pull` → `.osimart/schemas.json` |
16
- | POST | `theme-dev/sections/sync/` | `push`: the theme's `*Section.schema.json` → `StoreSection` (`managed_by=CODE`) |
17
-
18
- `sections/sync/` body: `{ sections: [{ type, name, group?, allowed_templates, schema: { settings, blocks, max_blocks? } }], prune, dry_run }`.
19
- Response: `{ created, updated, unchanged, deleted, kept_in_use }` (lists of types).
17
+ `<theme>` is `live`, `draft` or a theme id. `draft` is the token's own draft (an UNPUBLISHED theme): the
18
+ first push creates it by duplicating the live theme, and publishing it clears it, so the next
19
+ push starts a new one.
20
20
 
21
- ## New
21
+ | Method | Path | |
22
+ |---|---|---|
23
+ | GET | `themes/` | `[{id, name, role, version}]`; role is `LIVE`, `DRAFT` (this token's), `DEVELOPMENT` or `UNPUBLISHED` |
24
+ | GET | `theme/?theme=<theme>` | `{theme, settings, groups: {header, footer}, templates: [{type, suffix, data}]}` |
25
+ | PUT | `theme/` | `{theme, base_version?, dry_run?, settings?, groups?, templates?}` |
26
+ | POST | `themes/<id>/publish/` | makes it live |
27
+ | POST | `dev-session/` | `{theme, preview: {theme, preview_token}}` — this token's draft (created if missing), for `osimart-theme dev` |
28
+ | GET | `schemas/` | `{sections: {TYPE: schema}, theme: [groups]}` |
29
+ | POST | `sections/sync/` | `{sections: [{type, name, group?, allowed_templates, schema}], prune?, dry_run?}` |
22
30
 
23
- ### `GET theme-dev/themes/`
31
+ **Template data** (`theme/templates/*.json`, `theme/sections/*-group.json`):
24
32
  ```json
25
- [{ "id": "uuid", "name": "Base", "role": "LIVE", "updated_at": "…" },
26
- { "id": "uuid", "name": "Base (draft)", "role": "DRAFT", "updated_at": "…" }]
33
+ { "settings": {},
34
+ "sections": [
35
+ { "key": "hero-1a2b3c", "type": "SLIDESHOW", "name": "…", "disabled": false,
36
+ "starts_at": "…", "ends_at": "…",
37
+ "settings": { },
38
+ "blocks": [ { "key": "…", "type": "GROUP", "parent_key": "", "disabled": false,
39
+ "settings": { }, "relations": { "product": "<uuid>" } } ] } ] }
27
40
  ```
41
+ Blocks may also nest under a `blocks` key instead of using `parent_key`. A section or block
42
+ without a `key` gets one generated.
28
43
 
29
- ### `GET theme-dev/theme/?theme=<theme>`
30
- The theme's JSON exactly as stored, so a push of the same data is a no-op.
31
- ```json
32
- {
33
- "theme": { "id": "uuid", "name": "Base", "role": "LIVE", "version": "2026-10-12T13:40:11.120Z" },
34
- "settings": { "PRIMARY_COLOR": "#20336b" },
35
- "groups": {
36
- "header": { "sections": [ { "key": "…", "type": "HEADER", "settings": {}, "blocks": [], "disabled": false } ] },
37
- "footer": { "sections": [ … ] }
38
- },
39
- "templates": [
40
- { "type": "INDEX", "suffix": "", "data": { "sections": [ … ] } },
41
- { "type": "PRODUCT", "suffix": "alternate", "data": { "sections": [ … ] } },
42
- { "type": "PAGE", "suffix": "contact", "data": { "sections": [ … ] } }
43
- ]
44
- }
45
- ```
46
- - `version` is any value that changes on every save of the theme, its templates or its groups
47
- (`updated_at` of the newest row works).
48
- - `data` holds what the template stores (section order, settings, blocks); media stay as the
49
- ids the dashboard saves, not resolved URLs.
44
+ **Push rules:**
45
+ - Each template file is diffed against what's stored and applied as the editor's own ops, so it
46
+ gets the dashboard's validation: allowed sections, unknown settings, max blocks, static
47
+ blocks, locked branding, relations belonging to the store.
48
+ - Only changed values are sent, so pulling and pushing back unchanged is a no-op.
49
+ - Templates in the push are created or updated, never deleted. Sections and blocks missing
50
+ from a file are removed.
51
+ - A key reused with a different type is refused. Give the new section or block a new key.
52
+ - New sections get exactly the blocks the file lists; default blocks aren't seeded.
53
+ - `base_version` that doesn't match the theme's current version → `409`. The merchant
54
+ edited it in the dashboard after the pull.
55
+ - Errors → `400 {"INDEX": {"<section or block key>": {"SETTING": "message"}}}`. One bad
56
+ template means nothing is saved.
57
+ - `dry_run` validates and reports, then rolls back.
50
58
 
51
- ### `PUT theme-dev/theme/`
52
- ```json
53
- {
54
- "theme": "draft",
55
- "base_version": "2026-10-12T13:40:11.120Z",
56
- "dry_run": false,
57
- "settings": { … },
58
- "groups": { "header": { … }, "footer": { … } },
59
- "templates": [ { "type": "PRODUCT", "suffix": "alternate", "data": { … } } ]
60
- }
61
- ```
62
- - `theme: "draft"`: use the store's draft. If there is none, copy the live theme into a new
63
- draft first (the same as a theme update, so the live theme stays as the rollback).
64
- - `base_version` not null and different from the theme's current `version` → `409`
65
- (the merchant changed it in the dashboard after the pull). `null` = overwrite.
66
- - Any key left out (`settings`, `groups`, a template) is left as it is. Templates are
67
- created or replaced by `(type, suffix)` and never deleted here.
68
- - Validate everything the dashboard validates (`get_section_schemas(store)`: section types,
69
- setting ids, block types, `visible_if`) and answer `400` with field errors. On `dry_run`,
70
- validate and report only.
71
- - Response:
72
- ```json
73
- { "theme": { "id": "uuid", "name": "…", "role": "DRAFT", "version": "…" },
74
- "created": ["PRODUCT.alternate"], "updated": ["INDEX"], "unchanged": ["PRODUCT"],
75
- "preview_url": "https://store.com/?preview_theme=<id>" }
76
- ```
59
+ **Section sync:** creates or updates `StoreSection` rows marked `managed_by=CODE`. A built-in
60
+ type can't be redefined; to restyle one, override its `.vue` file instead. `prune` deletes CODE
61
+ rows missing from the repo, except ones still placed on a template (reported as `kept_in_use`).
77
62
 
78
- ### `POST theme-dev/themes/<id>/publish/`
79
- Makes that theme live (the previous live one becomes unpublished). Returns the theme
80
- (`{ id, name, role }`).
63
+ **Local dev (`osimart-theme dev`):** starts `nuxt dev`, opens a `dev-session/`, and on every save
64
+ under `theme/` pushes to the draft (a PUT `theme/` response for a non-live theme carries
65
+ `preview: {theme, preview_token}`). It writes `.osimart/dev.json`; `nuxt/osi-dev-theme.ts` reads it
66
+ on each server render and the storefront sends `?theme=<id>&preview_token=<t>` to
67
+ `store/apis/theme/bootstrap/` and `render/` (`theme_views.StorefrontThemeViewSet`), which then serve
68
+ that theme, uncached and with the preview headers. The browser reloads after each push. Live is
69
+ never touched; `push --theme live` or publishing the draft makes it live.
@@ -4,6 +4,9 @@ export function useStoreInit() {
4
4
  const bootstrapData = useState('bootstrap-data', () => null);
5
5
  const storeData = useState('store-data', () => null);
6
6
  const baseUrl = useState('base-url', () => '');
7
+ // `osimart-theme dev` only: the draft to render instead of the live theme.
8
+ // Null otherwise (nuxt/osi-dev-theme.ts).
9
+ const devTheme = useState('osi-dev-theme', () => null);
7
10
 
8
11
  const reqHeaders = import.meta.server ? useRequestHeaders(['host', 'x-forwarded-host', 'x-forwarded-proto']) : {};
9
12
  const browserHost =
@@ -36,6 +39,7 @@ const API_ROOT = storeApiBase
36
39
  const data = await storefrontService.bootstrap({
37
40
  baseUrl: API_ROOT,
38
41
  host: browserHost,
42
+ preview: devTheme.value,
39
43
  });
40
44
 
41
45
  bootstrapData.value = data;
@@ -48,5 +52,5 @@ const API_ROOT = storeApiBase
48
52
  return bootstrapData.value;
49
53
  }
50
54
 
51
- return { storeData, baseUrl, bootstrapData, init };
55
+ return { storeData, baseUrl, bootstrapData, devTheme, init };
52
56
  }
@@ -165,7 +165,7 @@ export function useStorefront() {
165
165
  // Refs survive an await fine — it's CREATING one that needs context. So
166
166
  // capture the ref now and read .value later.
167
167
  // ---------------------------------------------------------------------
168
- const { init, baseUrl, bootstrapData } = useStoreInit();
168
+ const { init, baseUrl, bootstrapData, devTheme } = useStoreInit();
169
169
 
170
170
  let requestHost = "";
171
171
  if (import.meta.server) {
@@ -214,8 +214,11 @@ export function useStorefront() {
214
214
  */
215
215
  async function render(path) {
216
216
  if (!baseUrl.value) await init();
217
- const args = { baseUrl: baseUrl.value, host: requestHost, path };
218
- return cachedRequest(`render:${args.baseUrl}:${path}`, () => storefrontService.render(args));
217
+ const preview = devTheme.value;
218
+ const args = { baseUrl: baseUrl.value, host: requestHost, path, preview };
219
+ // The draft's revision is in the key: a push must not be answered from cache.
220
+ const rev = preview ? `:${preview.theme}:${preview.rev ?? ""}` : "";
221
+ return cachedRequest(`render:${args.baseUrl}${rev}:${path}`, () => storefrontService.render(args));
219
222
  }
220
223
 
221
224
  /** A menu by handle, with a safe empty shape so layouts never have to guard. */
@@ -40,6 +40,18 @@ const TEXT_TRANSFORMS = {
40
40
  CAPITALIZE: "capitalize",
41
41
  };
42
42
 
43
+ /**
44
+ * A weight setting -> CSS font-weight. The schemas store numbers as strings
45
+ * ("700", FontWeight's values); older settings used names ("BOLD"). Before
46
+ * this accepted only names, so every "700" silently fell back to 400.
47
+ */
48
+ function toWeight(value) {
49
+ if (value === undefined || value === null || value === "") return undefined;
50
+ if (FONT_WEIGHTS[value]) return FONT_WEIGHTS[value];
51
+ const n = Number(value);
52
+ return Number.isFinite(n) && n >= 100 && n <= 900 ? n : undefined;
53
+ }
54
+
43
55
  export function useTextStyle(settings, options = {}) {
44
56
  const { prefix = "TEXT", defaults = {} } = options;
45
57
  const {
@@ -69,7 +81,7 @@ export function useTextStyle(settings, options = {}) {
69
81
  // Falls back to the desktop size, not to a constant: an unset mobile size
70
82
  // means "same as desktop", not "16px".
71
83
  "--osi-fs-mobile": `${num(get("SIZE_MOBILE"), base)}px`,
72
- "--osi-fw": FONT_WEIGHTS[get("WEIGHT")] ?? FONT_WEIGHTS[weight] ?? 400,
84
+ "--osi-fw": toWeight(get("WEIGHT")) ?? toWeight(weight) ?? 400,
73
85
  "--osi-lh": `${num(get("LINE_HEIGHT"), lineHeight) / 100}`,
74
86
  "--osi-ls": `${num(get("LETTER_SPACING"), 0) / 100}em`,
75
87
  "--osi-ff": FONT_FAMILIES[get("FONT")] ?? FONT_FAMILIES[font],
@@ -21,7 +21,7 @@ export default function useLogic({ props }) {
21
21
  SMALL: "320px",
22
22
  MEDIUM: "460px",
23
23
  LARGE: "620px",
24
- FULL: "100svh",
24
+ FULL: "100vh",
25
25
  };
26
26
 
27
27
  const position = computed(() =>
@@ -16,7 +16,7 @@ export default function useLogic({ props }) {
16
16
 
17
17
  /* --------------------------------------------------------- section shell */
18
18
 
19
- const HEIGHTS = { SMALL: "40vh", MEDIUM: "60vh", LARGE: "80vh", FULL: "100svh" };
19
+ const HEIGHTS = { SMALL: "40vh", MEDIUM: "60vh", LARGE: "80vh", FULL: "100vh" };
20
20
  const background = computed(() => backgroundOf(s.value));
21
21
 
22
22
  const sectionStyle = computed(() => {
@@ -91,7 +91,7 @@ export default function useLogic({ props }) {
91
91
 
92
92
  /* ------------------------------------------------------------------ sizing */
93
93
 
94
- const HEIGHTS = { SMALL: "320px", MEDIUM: "480px", LARGE: "640px", FULL: "100svh", ADAPT: "auto" };
94
+ const HEIGHTS = { SMALL: "320px", MEDIUM: "480px", LARGE: "640px", FULL: "100vh", ADAPT: "auto" };
95
95
 
96
96
  const vars = computed(() => {
97
97
  const v = s.value;
@@ -174,7 +174,7 @@ export default function useLogic({ props }) {
174
174
  /* ------------------------------------------------------------------ sizing */
175
175
 
176
176
  const RATIOS = { WIDE: "16 / 9", CLASSIC: "4 / 3", SQUARE: "1 / 1", PORTRAIT: "4 / 5", VERTICAL: "9 / 16", CINEMA: "21 / 9" };
177
- const HEIGHTS = { SMALL: "40vh", MEDIUM: "60vh", LARGE: "80vh", FULL: "100svh" };
177
+ const HEIGHTS = { SMALL: "40vh", MEDIUM: "60vh", LARGE: "80vh", FULL: "100vh" };
178
178
 
179
179
  const frameVars = computed(() => {
180
180
  const v = s.value;
@@ -2,6 +2,7 @@
2
2
  /**
3
3
  * osimart-theme — the theme repo's link to its store, like the Shopify CLI.
4
4
  *
5
+ * osimart-theme dev [nuxt dev args] run the site on your draft; every save in theme/ is pushed to it
5
6
  * osimart-theme themes list the store's themes (id, name, role)
6
7
  * osimart-theme pull [--theme live|<id>] [--delete]
7
8
  * osimart-theme push [--theme draft|live|<id>] [--force] [--prune] [--dry-run]
@@ -23,7 +24,8 @@
23
24
  *
24
25
  * Backend contract: THEME_DEV_API.md in @ositcom/osimart-storefront.
25
26
  */
26
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import { spawn } from "node:child_process";
28
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
27
29
  import { basename, join, resolve } from "node:path";
28
30
 
29
31
  const ROOT = process.cwd();
@@ -34,6 +36,7 @@ const SETTINGS_FILE = join(THEME_DIR, "config", "settings_data.json");
34
36
  const SECTIONS_DIR = join(ROOT, "app", "sections");
35
37
  const STATE_DIR = join(ROOT, ".osimart");
36
38
  const STATE_FILE = join(STATE_DIR, "state.json");
39
+ const DEV_FILE = join(STATE_DIR, "dev.json");
37
40
 
38
41
  /* ---------------------------------------------------------------- env */
39
42
 
@@ -47,9 +50,10 @@ function loadDotenv() {
47
50
  }
48
51
  loadDotenv();
49
52
 
53
+ /** Thrown, not exit(): `dev` reports it and keeps watching. */
54
+ class CliError extends Error {}
50
55
  function fail(message) {
51
- console.error(`✖ ${message}`);
52
- process.exit(1);
56
+ throw new CliError(message);
53
57
  }
54
58
 
55
59
  /**
@@ -57,14 +61,16 @@ function fail(message) {
57
61
  * already talks to (NUXT_PUBLIC_STORE_API_BASE). OSIMART_THEME_DEV_URL only
58
62
  * overrides it if the routes are mounted somewhere other than /store/apis/theme-dev/.
59
63
  */
60
- const BUILDER_PATH = "/store/apis/theme-dev/";
64
+ // Relative, like the storefront's own `${storeApiBase}store/apis/...` calls, so a
65
+ // base with a path (https://x.myosimart.com/api/) keeps it.
66
+ const BUILDER_PATH = "store/apis/theme-dev/";
61
67
 
62
68
  function baseUrl() {
63
69
  const override = process.env.OSIMART_THEME_DEV_URL;
64
70
  if (override) return override.endsWith("/") ? override : `${override}/`;
65
71
  const api = process.env.NUXT_PUBLIC_STORE_API_BASE;
66
72
  if (!api) fail("Set NUXT_PUBLIC_STORE_API_BASE in .env (the store's API, e.g. http://test6.192.168.1.12.nip.io:8000/)");
67
- return new URL(BUILDER_PATH, api).toString();
73
+ return new URL(BUILDER_PATH, api.endsWith("/") ? api : `${api}/`).toString();
68
74
  }
69
75
 
70
76
  async function request(method, path, body) {
@@ -80,7 +86,7 @@ async function request(method, path, body) {
80
86
  let data = null;
81
87
  try { data = text ? JSON.parse(text) : null; } catch { data = { detail: `not a theme-dev endpoint: ${url}` }; }
82
88
  if (!res.ok) {
83
- const err = new Error(data?.detail || data?.message || `HTTP ${res.status}`);
89
+ const err = new Error(`${data?.detail || data?.message || `HTTP ${res.status}`} (${method} ${url})`);
84
90
  err.status = res.status;
85
91
  err.data = data;
86
92
  throw err;
@@ -202,10 +208,17 @@ async function pull(args) {
202
208
  }
203
209
 
204
210
  async function push(args) {
205
- const dryRun = args.includes("--dry-run");
206
- const force = args.includes("--force");
207
- const prune = args.includes("--prune");
208
- const theme = flag(args, "--theme", "draft");
211
+ await pushOnce({
212
+ dryRun: args.includes("--dry-run"),
213
+ force: args.includes("--force"),
214
+ prune: args.includes("--prune"),
215
+ theme: flag(args, "--theme", "draft"),
216
+ });
217
+ }
218
+
219
+ /** Returns the PUT theme/ response, or null when theme/ is empty. */
220
+ async function pushOnce({ dryRun = false, force = false, prune = false, theme = "draft", quiet = false } = {}) {
221
+ const log = quiet ? () => {} : console.log;
209
222
 
210
223
  // 1. Section types the theme adds (their schemas), so the editor can place them.
211
224
  const sections = readLocalSchemas();
@@ -219,17 +232,17 @@ async function push(args) {
219
232
  if (e.status === 400 && e.data) fail(`Section schemas rejected:\n${JSON.stringify(e.data, null, 2)}`);
220
233
  throw e;
221
234
  }
222
- console.log(`${dryRun ? "◌" : "✔"} section schemas: ${sections.length}`);
235
+ log(`${dryRun ? "◌" : "✔"} section schemas: ${sections.length}`);
223
236
  for (const key of ["created", "updated", "unchanged", "deleted", "kept_in_use"]) {
224
- if (report?.[key]?.length) console.log(` ${key.padEnd(11)} ${report[key].join(", ")}`);
237
+ if (report?.[key]?.length) log(` ${key.padEnd(11)} ${report[key].join(", ")}`);
225
238
  }
226
239
  }
227
240
 
228
241
  // 2. Templates, groups, settings.
229
242
  const local = readLocalTheme();
230
243
  if (!local.templates.length && !Object.keys(local.groups).length && local.settings === undefined) {
231
- console.log(" nothing in theme/ to push (run pull first)");
232
- return;
244
+ log(" nothing in theme/ to push (run pull first)");
245
+ return null;
233
246
  }
234
247
  const { version, theme_id, role, store_id } = state();
235
248
  const sameTheme = theme === theme_id || theme === String(role || "").toLowerCase();
@@ -243,12 +256,12 @@ async function push(args) {
243
256
  dry_run: dryRun,
244
257
  ...local,
245
258
  });
246
- console.log(`${dryRun ? "◌ dry run" : "✔ pushed"} to "${res.theme.name}" (${res.theme.role}) on ${res.theme.store?.name ?? "the store"} — ${local.templates.length} templates`);
259
+ log(`${dryRun ? "◌ dry run" : "✔ pushed"} to "${res.theme.name}" (${res.theme.role}) on ${res.theme.store?.name ?? "the store"} — ${local.templates.length} templates`);
247
260
  for (const key of ["created", "updated", "unchanged"]) {
248
- if (res[key]?.length) console.log(` ${key.padEnd(9)} ${res[key].join(", ")}`);
261
+ if (res[key]?.length) log(` ${key.padEnd(9)} ${res[key].join(", ")}`);
249
262
  }
250
- if (res.preview_url) console.log(` preview: ${res.preview_url}`);
251
263
  if (!dryRun && res.theme.id === theme_id) saveState({ version: res.theme.version });
264
+ return res;
252
265
  } catch (e) {
253
266
  if (e.status === 409) {
254
267
  fail("The theme changed on the store since your last pull (edited in the dashboard).\n Run `osimart-theme pull`, merge with git, then push — or push --force to overwrite.");
@@ -258,6 +271,105 @@ async function push(args) {
258
271
  }
259
272
  }
260
273
 
274
+ /* ---------------------------------------------------------------- dev */
275
+
276
+ /**
277
+ * `npm run dev`: Nuxt on your draft instead of the live theme, like
278
+ * `shopify theme dev`.
279
+ *
280
+ * 1. starts `nuxt dev` (any args are passed on, e.g. --host);
281
+ * 2. gets this token's draft (created from live the first time);
282
+ * 3. on every save under theme/ (or a section .schema.json), pushes to the
283
+ * draft and writes .osimart/dev.json; the site reloads itself.
284
+ *
285
+ * The live theme is never touched. When it looks right:
286
+ * npm run theme:push -- --theme live (or publish the draft in the dashboard)
287
+ *
288
+ * No OSIMART_THEME_TOKEN: just `nuxt dev` on the live theme.
289
+ */
290
+ async function dev(args) {
291
+ rmSync(DEV_FILE, { force: true });
292
+ const nuxt = spawn("nuxt", ["dev", ...args], { stdio: "inherit", shell: true });
293
+ const stop = (code = 0) => {
294
+ rmSync(DEV_FILE, { force: true });
295
+ if (nuxt.exitCode === null) nuxt.kill();
296
+ process.exit(code);
297
+ };
298
+ nuxt.on("exit", (code) => stop(code ?? 0));
299
+ process.on("SIGINT", () => stop(0));
300
+ process.on("SIGTERM", () => stop(0));
301
+
302
+ if (!process.env.OSIMART_THEME_TOKEN) {
303
+ console.log("◌ OSIMART_THEME_TOKEN not set: showing the live theme, theme/ changes are not synced.");
304
+ return;
305
+ }
306
+
307
+ let rev = 0;
308
+ let preview = null;
309
+ const writeDev = () => writeJson(DEV_FILE, { ...preview, rev: ++rev });
310
+
311
+ try {
312
+ const session = await request("POST", "dev-session/");
313
+ preview = session.preview;
314
+ console.log(`✔ dev: showing your draft "${session.theme.name}"; saves in theme/ are pushed to it. Live is untouched.`);
315
+ } catch (e) {
316
+ console.error(`✖ dev: could not open the draft (${e.message}). Showing the live theme.`);
317
+ return;
318
+ }
319
+
320
+ let running = false;
321
+ let again = false;
322
+ const sync = async () => {
323
+ if (running) { again = true; return; }
324
+ running = true;
325
+ try {
326
+ const bad = invalidJsonFiles();
327
+ if (bad.length) {
328
+ console.log(`… waiting for valid JSON: ${bad.join(", ")}`);
329
+ return;
330
+ }
331
+ const res = await pushOnce({ quiet: true });
332
+ if (res?.preview) preview = res.preview;
333
+ const changed = [...(res?.created ?? []), ...(res?.updated ?? [])];
334
+ console.log(`✔ ${new Date().toLocaleTimeString()} synced${changed.length ? `: ${changed.join(", ")}` : " (no changes)"}`);
335
+ writeDev();
336
+ } catch (e) {
337
+ const detail = e.status === 400 && e.data ? `\n${JSON.stringify(e.data, null, 2)}` : "";
338
+ console.error(`✖ ${new Date().toLocaleTimeString()} not synced: ${e.status ? `${e.status} ` : ""}${e.message}${detail}`);
339
+ } finally {
340
+ running = false;
341
+ if (again) { again = false; schedule(); }
342
+ }
343
+ };
344
+
345
+ // Autosave writes often; push once the saves stop for a moment.
346
+ let timer;
347
+ const schedule = () => { clearTimeout(timer); timer = setTimeout(sync, 700); };
348
+
349
+ await sync();
350
+ if (existsSync(THEME_DIR)) watch(THEME_DIR, { recursive: true }, (_e, name) => {
351
+ if (!name || name.endsWith(".json")) schedule();
352
+ });
353
+ if (existsSync(SECTIONS_DIR)) watch(SECTIONS_DIR, (_e, name) => {
354
+ if (!name || name.endsWith(".schema.json")) schedule();
355
+ });
356
+ }
357
+
358
+ /** Files under theme/ (and section schemas) that don't parse yet — mid-edit. */
359
+ function invalidJsonFiles() {
360
+ const files = [
361
+ ...(existsSync(SETTINGS_FILE) ? [SETTINGS_FILE] : []),
362
+ ...listJson(GROUPS_DIR).map((f) => join(GROUPS_DIR, f)),
363
+ ...listJson(TEMPLATES_DIR).map((f) => join(TEMPLATES_DIR, f)),
364
+ ...(existsSync(SECTIONS_DIR)
365
+ ? readdirSync(SECTIONS_DIR).filter((f) => f.endsWith(".schema.json")).map((f) => join(SECTIONS_DIR, f))
366
+ : []),
367
+ ];
368
+ return files.filter((file) => {
369
+ try { JSON.parse(readFileSync(file, "utf8")); return false; } catch { return true; }
370
+ }).map((file) => file.slice(ROOT.length + 1));
371
+ }
372
+
261
373
  async function publish(args) {
262
374
  const id = args[0];
263
375
  if (!id) fail("Usage: osimart-theme publish <theme-id> (see: osimart-theme themes)");
@@ -268,10 +380,11 @@ async function publish(args) {
268
380
  /* ---------------------------------------------------------------- main */
269
381
 
270
382
  const [command, ...args] = process.argv.slice(2);
271
- const commands = { themes, pull, push, publish };
383
+ const commands = { dev, themes, pull, push, publish };
272
384
 
273
385
  if (!commands[command]) {
274
386
  console.log(`Usage:
387
+ osimart-theme dev [nuxt dev args]
275
388
  osimart-theme themes
276
389
  osimart-theme pull [--theme live|<id>] [--delete]
277
390
  osimart-theme push [--theme draft|live|<id>] [--force] [--prune] [--dry-run]
@@ -279,4 +392,7 @@ if (!commands[command]) {
279
392
  process.exit(command ? 1 : 0);
280
393
  }
281
394
 
282
- commands[command](args).catch((e) => fail(e.status ? `${e.status} ${e.message}` : e.message));
395
+ commands[command](args).catch((e) => {
396
+ console.error(`✖ ${e.status ? `${e.status} ` : ""}${e.message}`);
397
+ process.exit(1);
398
+ });
@@ -0,0 +1,67 @@
1
+ import { existsSync, mkdirSync, watch } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { addPluginTemplate, defineNuxtModule } from "@nuxt/kit";
4
+
5
+ /**
6
+ * `osimart-theme dev` support (dev server only; does nothing in a build).
7
+ *
8
+ * The CLI pushes the theme repo's `theme/` files to the developer's draft on
9
+ * every save and writes `.osimart/dev.json`:
10
+ *
11
+ * { "theme": "<draft id>", "preview_token": "…", "rev": 3 }
12
+ *
13
+ * This module
14
+ * - reads that file on every server render into useState("osi-dev-theme"),
15
+ * so bootstrap/ and render/ ask the backend for the draft instead of the
16
+ * live theme (composables/useStoreInit.js, useStorefront.js);
17
+ * - reloads the open browser tabs when it changes, i.e. after each push.
18
+ *
19
+ * No file (plain `nuxt dev`, or the CLI isn't running): the live theme, as before.
20
+ */
21
+ export default defineNuxtModule({
22
+ meta: { name: "osi-dev-theme" },
23
+ setup(_options, nuxt) {
24
+ if (!nuxt.options.dev) return;
25
+
26
+ const dir = join(nuxt.options.rootDir, ".osimart");
27
+ const file = join(dir, "dev.json");
28
+
29
+ addPluginTemplate({
30
+ filename: "osi-dev-theme.server.mjs",
31
+ mode: "server",
32
+ getContents: () => `
33
+ import { readFileSync } from "node:fs";
34
+ import { defineNuxtPlugin, useState } from "#imports";
35
+
36
+ const FILE = ${JSON.stringify(file)};
37
+
38
+ export default defineNuxtPlugin({
39
+ name: "osi-dev-theme",
40
+ enforce: "pre",
41
+ setup() {
42
+ let value = null;
43
+ try {
44
+ const data = JSON.parse(readFileSync(FILE, "utf8"));
45
+ if (data && data.theme && data.preview_token) value = data;
46
+ } catch {}
47
+ useState("osi-dev-theme", () => null).value = value;
48
+ },
49
+ });
50
+ `,
51
+ });
52
+
53
+ // Reload the browser after a push. Vite only reloads for files in its
54
+ // module graph, and dev.json isn't one, so tell its client directly.
55
+ nuxt.hook("vite:serverCreated", (server, { isClient }) => {
56
+ if (!isClient) return;
57
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
58
+ let timer: ReturnType<typeof setTimeout> | undefined;
59
+ const watcher = watch(dir, (_event, name) => {
60
+ if (name && name !== "dev.json") return;
61
+ clearTimeout(timer);
62
+ timer = setTimeout(() => server.ws.send({ type: "full-reload", path: "*" }), 150);
63
+ });
64
+ nuxt.hook("close", () => watcher.close());
65
+ });
66
+ },
67
+ });
package/nuxt.config.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import osiSections from "./nuxt/osi-sections";
3
+ import osiDevTheme from "./nuxt/osi-dev-theme";
3
4
 
4
5
  /**
5
- * @ositcom/create-storefront — the platform. Extended by every theme repo:
6
+ * @ositcom/osimart-storefront — the platform. Extended by every theme repo:
6
7
  *
7
- * extends: ["@ositcom/create-storefront"]
8
+ * extends: ["@ositcom/osimart-storefront"]
8
9
  *
9
10
  * This package holds the logic (composables, services, the script of every
10
11
  * theme section/component under app/logic), the platform pages (routing,
@@ -31,7 +32,7 @@ export default defineNuxtConfig({
31
32
  // creating a component with the same file name anywhere in its components/.
32
33
  components: [{ path: `${appDir}/components`, pathPrefix: false }],
33
34
 
34
- modules: ["@nuxtjs/sitemap", "@pinia/nuxt", osiSections],
35
+ modules: ["@nuxtjs/sitemap", "@pinia/nuxt", osiSections, osiDevTheme],
35
36
 
36
37
  ssr: true,
37
38
 
@@ -50,6 +51,8 @@ export default defineNuxtConfig({
50
51
 
51
52
  runtimeConfig: {
52
53
  public: {
54
+ // Empty in production: the API is same-origin at https://<host>/api/.
55
+ // Set NUXT_PUBLIC_STORE_API_BASE in dev, e.g. https://api.offsiders.myosimart.com/
53
56
  storeApiBase: "",
54
57
  },
55
58
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osimart/create-storefront",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Osimart storefront platform — logic, platform pages and CLI; extended by every theme repo",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",