@osimart/create-storefront 1.0.0 → 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/README.md +2 -2
- package/THEME_DEV_API.md +57 -68
- 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/app/plugins/{geo.ts → geo.js} +1 -1
- package/app/plugins/{vue-query.ts → vue-query.js} +1 -1
- package/bin/osimart-theme.mjs +167 -28
- package/nuxt/osi-dev-theme.ts +67 -0
- package/nuxt.config.ts +4 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @osimart/
|
|
1
|
+
# @osimart/create-storefront
|
|
2
2
|
|
|
3
3
|
The Osimart storefront **platform**, published as a private Nuxt layer. It has no look of
|
|
4
4
|
its own. Every theme repo extends it:
|
|
@@ -77,5 +77,5 @@ developer (read).
|
|
|
77
77
|
## Working on the platform and a theme together
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
|
-
cd osimart-theme-base && npm link ../
|
|
80
|
+
cd osimart-theme-base && npm link ../create-storefront && npm run dev
|
|
81
81
|
```
|
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.
|
|
@@ -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]
|
|
@@ -18,11 +19,13 @@
|
|
|
18
19
|
*
|
|
19
20
|
* Env (read from .env too):
|
|
20
21
|
* OSIMART_THEME_TOKEN Theme access token (dashboard → Theme access)
|
|
21
|
-
*
|
|
22
|
+
* NUXT_PUBLIC_STORE_API_BASE the store's API (already set for `npm run dev`); the CLI calls
|
|
23
|
+
* <its origin>/store/apis/theme-dev/
|
|
22
24
|
*
|
|
23
25
|
* Backend contract: THEME_DEV_API.md in @ositcom/osimart-storefront.
|
|
24
26
|
*/
|
|
25
|
-
import {
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
26
29
|
import { basename, join, resolve } from "node:path";
|
|
27
30
|
|
|
28
31
|
const ROOT = process.cwd();
|
|
@@ -33,6 +36,7 @@ const SETTINGS_FILE = join(THEME_DIR, "config", "settings_data.json");
|
|
|
33
36
|
const SECTIONS_DIR = join(ROOT, "app", "sections");
|
|
34
37
|
const STATE_DIR = join(ROOT, ".osimart");
|
|
35
38
|
const STATE_FILE = join(STATE_DIR, "state.json");
|
|
39
|
+
const DEV_FILE = join(STATE_DIR, "dev.json");
|
|
36
40
|
|
|
37
41
|
/* ---------------------------------------------------------------- env */
|
|
38
42
|
|
|
@@ -46,32 +50,43 @@ function loadDotenv() {
|
|
|
46
50
|
}
|
|
47
51
|
loadDotenv();
|
|
48
52
|
|
|
53
|
+
/** Thrown, not exit(): `dev` reports it and keeps watching. */
|
|
54
|
+
class CliError extends Error {}
|
|
49
55
|
function fail(message) {
|
|
50
|
-
|
|
51
|
-
process.exit(1);
|
|
56
|
+
throw new CliError(message);
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The builder API's theme-dev route, on the same backend the storefront
|
|
61
|
+
* already talks to (NUXT_PUBLIC_STORE_API_BASE). OSIMART_THEME_DEV_URL only
|
|
62
|
+
* overrides it if the routes are mounted somewhere other than /store/apis/theme-dev/.
|
|
63
|
+
*/
|
|
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/";
|
|
67
|
+
|
|
54
68
|
function baseUrl() {
|
|
55
|
-
const
|
|
69
|
+
const override = process.env.OSIMART_THEME_DEV_URL;
|
|
70
|
+
if (override) return override.endsWith("/") ? override : `${override}/`;
|
|
56
71
|
const api = process.env.NUXT_PUBLIC_STORE_API_BASE;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return url.endsWith("/") ? url : `${url}/`;
|
|
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/)");
|
|
73
|
+
return new URL(BUILDER_PATH, api.endsWith("/") ? api : `${api}/`).toString();
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
async function request(method, path, body) {
|
|
63
77
|
const token = process.env.OSIMART_THEME_TOKEN;
|
|
64
78
|
if (!token) fail("Set OSIMART_THEME_TOKEN in .env (dashboard → Theme access)");
|
|
65
|
-
const
|
|
79
|
+
const url = new URL(path, baseUrl());
|
|
80
|
+
const res = await fetch(url, {
|
|
66
81
|
method,
|
|
67
82
|
headers: { Authorization: `ThemeToken ${token}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
68
83
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
69
84
|
});
|
|
70
85
|
const text = await res.text();
|
|
71
86
|
let data = null;
|
|
72
|
-
try { data = text ? JSON.parse(text) : null; } catch { data = { detail:
|
|
87
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: `not a theme-dev endpoint: ${url}` }; }
|
|
73
88
|
if (!res.ok) {
|
|
74
|
-
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})`);
|
|
75
90
|
err.status = res.status;
|
|
76
91
|
err.data = data;
|
|
77
92
|
throw err;
|
|
@@ -184,48 +199,69 @@ async function pull(args) {
|
|
|
184
199
|
console.log(` (schemas not saved: ${e.message})`);
|
|
185
200
|
}
|
|
186
201
|
|
|
187
|
-
saveState({
|
|
188
|
-
|
|
202
|
+
saveState({
|
|
203
|
+
store_id: data.theme.store?.id, store_name: data.theme.store?.name,
|
|
204
|
+
theme_id: data.theme.id, theme_name: data.theme.name, role: data.theme.role,
|
|
205
|
+
version: data.theme.version, pulled_at: new Date().toISOString(),
|
|
206
|
+
});
|
|
207
|
+
console.log(`✔ pulled "${data.theme.name}" (${data.theme.role}) from ${data.theme.store?.name ?? "the store"} — ${data.templates?.length ?? 0} templates, ${Object.keys(groups).length} groups`);
|
|
189
208
|
}
|
|
190
209
|
|
|
191
210
|
async function push(args) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
|
196
222
|
|
|
197
223
|
// 1. Section types the theme adds (their schemas), so the editor can place them.
|
|
198
224
|
const sections = readLocalSchemas();
|
|
199
225
|
if (sections.length || prune) {
|
|
200
|
-
|
|
201
|
-
|
|
226
|
+
let report;
|
|
227
|
+
try {
|
|
228
|
+
report = await request("POST", "sections/sync/", {
|
|
229
|
+
sections, prune, dry_run: dryRun, expected_store: force ? null : state().store_id ?? null,
|
|
230
|
+
});
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e.status === 400 && e.data) fail(`Section schemas rejected:\n${JSON.stringify(e.data, null, 2)}`);
|
|
233
|
+
throw e;
|
|
234
|
+
}
|
|
235
|
+
log(`${dryRun ? "◌" : "✔"} section schemas: ${sections.length}`);
|
|
202
236
|
for (const key of ["created", "updated", "unchanged", "deleted", "kept_in_use"]) {
|
|
203
|
-
if (report?.[key]?.length)
|
|
237
|
+
if (report?.[key]?.length) log(` ${key.padEnd(11)} ${report[key].join(", ")}`);
|
|
204
238
|
}
|
|
205
239
|
}
|
|
206
240
|
|
|
207
241
|
// 2. Templates, groups, settings.
|
|
208
242
|
const local = readLocalTheme();
|
|
209
243
|
if (!local.templates.length && !Object.keys(local.groups).length && local.settings === undefined) {
|
|
210
|
-
|
|
211
|
-
return;
|
|
244
|
+
log(" nothing in theme/ to push (run pull first)");
|
|
245
|
+
return null;
|
|
212
246
|
}
|
|
213
|
-
const { version, theme_id, role } = state();
|
|
247
|
+
const { version, theme_id, role, store_id } = state();
|
|
214
248
|
const sameTheme = theme === theme_id || theme === String(role || "").toLowerCase();
|
|
215
249
|
try {
|
|
216
250
|
const res = await request("PUT", "theme/", {
|
|
217
251
|
theme,
|
|
218
252
|
// The merchant may have edited the theme in the dashboard since the pull.
|
|
219
253
|
base_version: force || !sameTheme ? null : version ?? null,
|
|
254
|
+
// Files pulled from another store (token swapped)? The backend refuses.
|
|
255
|
+
expected_store: force ? null : store_id ?? null,
|
|
220
256
|
dry_run: dryRun,
|
|
221
257
|
...local,
|
|
222
258
|
});
|
|
223
|
-
|
|
259
|
+
log(`${dryRun ? "◌ dry run" : "✔ pushed"} to "${res.theme.name}" (${res.theme.role}) on ${res.theme.store?.name ?? "the store"} — ${local.templates.length} templates`);
|
|
224
260
|
for (const key of ["created", "updated", "unchanged"]) {
|
|
225
|
-
if (res[key]?.length)
|
|
261
|
+
if (res[key]?.length) log(` ${key.padEnd(9)} ${res[key].join(", ")}`);
|
|
226
262
|
}
|
|
227
|
-
if (res.preview_url) console.log(` preview: ${res.preview_url}`);
|
|
228
263
|
if (!dryRun && res.theme.id === theme_id) saveState({ version: res.theme.version });
|
|
264
|
+
return res;
|
|
229
265
|
} catch (e) {
|
|
230
266
|
if (e.status === 409) {
|
|
231
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.");
|
|
@@ -235,6 +271,105 @@ async function push(args) {
|
|
|
235
271
|
}
|
|
236
272
|
}
|
|
237
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
|
+
|
|
238
373
|
async function publish(args) {
|
|
239
374
|
const id = args[0];
|
|
240
375
|
if (!id) fail("Usage: osimart-theme publish <theme-id> (see: osimart-theme themes)");
|
|
@@ -245,10 +380,11 @@ async function publish(args) {
|
|
|
245
380
|
/* ---------------------------------------------------------------- main */
|
|
246
381
|
|
|
247
382
|
const [command, ...args] = process.argv.slice(2);
|
|
248
|
-
const commands = { themes, pull, push, publish };
|
|
383
|
+
const commands = { dev, themes, pull, push, publish };
|
|
249
384
|
|
|
250
385
|
if (!commands[command]) {
|
|
251
386
|
console.log(`Usage:
|
|
387
|
+
osimart-theme dev [nuxt dev args]
|
|
252
388
|
osimart-theme themes
|
|
253
389
|
osimart-theme pull [--theme live|<id>] [--delete]
|
|
254
390
|
osimart-theme push [--theme draft|live|<id>] [--force] [--prune] [--dry-run]
|
|
@@ -256,4 +392,7 @@ if (!commands[command]) {
|
|
|
256
392
|
process.exit(command ? 1 : 0);
|
|
257
393
|
}
|
|
258
394
|
|
|
259
|
-
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,5 +1,6 @@
|
|
|
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
6
|
* @ositcom/osimart-storefront — the platform. Extended by every theme repo:
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osimart/create-storefront",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Osimart storefront platform
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -43,4 +43,4 @@
|
|
|
43
43
|
},
|
|
44
44
|
"license": "UNLICENSED",
|
|
45
45
|
"homepage": "https://osimart.com"
|
|
46
|
-
}
|
|
46
|
+
}
|