@osimart/create-storefront 1.0.1 → 1.0.3
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 +57 -68
- package/app/app.vue +0 -1
- package/app/composables/useStoreInit.js +5 -1
- package/app/composables/useStorefront.js +6 -3
- package/app/composables/useTextStyles.js +13 -1
- package/app/logic/components/ClassificationFeatured.js +1 -1
- package/app/logic/sections/MediaContentSection.js +1 -1
- package/app/logic/sections/SlideshowSection.js +1 -1
- package/app/logic/sections/VideoSection.js +1 -1
- package/bin/osimart-theme.mjs +135 -19
- package/nuxt/osi-dev-theme.ts +67 -0
- package/nuxt.config.ts +6 -3
- package/package.json +1 -1
package/THEME_DEV_API.md
CHANGED
|
@@ -1,80 +1,69 @@
|
|
|
1
|
-
# Theme dev API (
|
|
1
|
+
# Theme dev API (what `osimart-theme` calls)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
storefront call.
|
|
3
|
+
Implemented in the backend's `builder/theme_dev.py`. Routes:
|
|
5
4
|
|
|
6
|
-
**
|
|
7
|
-
|
|
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
|
-
|
|
10
|
+
The store comes from the token, never the host. `OSIMART_THEME_DEV_URL` overrides the CLI's address.
|
|
10
11
|
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
**Template data** (`theme/templates/*.json`, `theme/sections/*-group.json`):
|
|
24
32
|
```json
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
package/app/app.vue
CHANGED
|
@@ -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
|
|
218
|
-
|
|
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":
|
|
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],
|
|
@@ -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: "
|
|
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: "
|
|
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: "
|
|
177
|
+
const HEIGHTS = { SMALL: "40vh", MEDIUM: "60vh", LARGE: "80vh", FULL: "100vh" };
|
|
178
178
|
|
|
179
179
|
const frameVars = computed(() => {
|
|
180
180
|
const v = s.value;
|
package/bin/osimart-theme.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
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/
|
|
6
|
+
* @ositcom/osimart-storefront — the platform. Extended by every theme repo:
|
|
6
7
|
*
|
|
7
|
-
* extends: ["@ositcom/
|
|
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