@lightspeed/crane 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/app.d.mts +616 -8
  3. package/dist/app.d.ts +616 -8
  4. package/dist/cli.mjs +7 -7
  5. package/package.json +3 -3
  6. package/template/_gitignore +1 -0
  7. package/template/preview/sections/preview.html +2 -0
  8. package/template/preview/shared/mock.ts +175 -0
  9. package/template/preview/shared/preview.ts +104 -26
  10. package/template/preview/vite.config.js +49 -7
  11. package/template/reference/templates/reference-template-apparel/configuration.ts +22 -0
  12. package/template/reference/templates/reference-template-apparel/pages/catalog.ts +10 -0
  13. package/template/reference/templates/reference-template-apparel/pages/category.ts +10 -0
  14. package/template/reference/templates/reference-template-apparel/pages/home.ts +24 -0
  15. package/template/reference/templates/reference-template-apparel/pages/product.ts +10 -0
  16. package/template/reference/templates/reference-template-bike/configuration.ts +22 -0
  17. package/template/reference/templates/reference-template-bike/pages/catalog.ts +10 -0
  18. package/template/reference/templates/reference-template-bike/pages/category.ts +10 -0
  19. package/template/reference/templates/reference-template-bike/pages/home.ts +24 -0
  20. package/template/reference/templates/reference-template-bike/pages/product.ts +10 -0
  21. package/template/reference/templates/reference-template-apparel.ts +0 -44
  22. package/template/reference/templates/reference-template-bike.ts +0 -44
  23. package/template/templates/assets/template_cover_image.png +0 -0
  24. package/template/templates/template.ts +0 -198
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightspeed/crane",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "bin": "bin/crane.js",
6
6
  "main": "./dist/app.mjs",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@jridgewell/sourcemap-codec": "^1.4.15",
65
- "@lightspeed/eslint-config-crane": "1.1.1",
65
+ "@lightspeed/eslint-config-crane": "1.1.2",
66
66
  "@types/prompts": "^2.4.2",
67
67
  "@vitejs/plugin-vue": "^4.1.0",
68
68
  "adm-zip": "^0.5.16",
@@ -72,7 +72,7 @@
72
72
  "axios-concurrency": "^1.0.4",
73
73
  "cac": "^6.7.14",
74
74
  "cli-progress": "^3.12.0",
75
- "eslint": "9.22.0",
75
+ "eslint": "^9.33.0",
76
76
  "fs-extra": "^11.1.1",
77
77
  "glob": "^9.3.5",
78
78
  "jsonpath-plus": "^10.3.0",
@@ -9,6 +9,7 @@ lerna-debug.log*
9
9
 
10
10
  node_modules
11
11
  dist
12
+ preview
12
13
  dist-ssr
13
14
  *.local
14
15
  crane.config.json
@@ -109,6 +109,8 @@
109
109
 
110
110
  // Render the showcase
111
111
  renderShowcase(sectionName, showcaseId);
112
+ fetch(`${window.location.origin}/chosen-section/${sectionName}`)
113
+ .catch(console.error);
112
114
  });
113
115
 
114
116
  // Restore saved showcase or render first available
@@ -0,0 +1,175 @@
1
+
2
+ export type ExternalContentMock = ExternalContentData
3
+
4
+ /**
5
+ * Default mock configuration
6
+ * Users can modify this in the compiled mock.js file
7
+ */
8
+ export const externalContentMock: ExternalContentMock = {
9
+ site: {
10
+ isPreviewMode: true,
11
+ account: {
12
+ title: 'My Account',
13
+ url: '/account',
14
+ target: '_self'
15
+ },
16
+ cart: {
17
+ url: '/cart',
18
+ count: 3
19
+ },
20
+ languages: [
21
+ {
22
+ code: 'en',
23
+ description: 'English',
24
+ main: true,
25
+ selected: true,
26
+ url: '/en'
27
+ },
28
+ {
29
+ code: 'es',
30
+ description: 'Español',
31
+ main: false,
32
+ selected: false,
33
+ url: '/es'
34
+ }
35
+ ],
36
+ legalPages: [
37
+ {
38
+ title: 'Privacy Policy',
39
+ url: '/privacy'
40
+ },
41
+ {
42
+ title: 'Terms of Service',
43
+ url: '/terms'
44
+ },
45
+ {
46
+ title: 'Refund Policy',
47
+ url: '/refunds'
48
+ }
49
+ ],
50
+ reportAbuse: {
51
+ title: 'Report Abuse',
52
+ url: '/report-abuse',
53
+ target: '_blank'
54
+ },
55
+ madeWith: {
56
+ url: 'https://www.lightspeedhq.com',
57
+ target: '_blank',
58
+ icon: '/assets/lightspeed-icon.png',
59
+ poweredBy: 'Powered by',
60
+ company: 'Lightspeed'
61
+ }
62
+ },
63
+
64
+ category: {
65
+ categories: [
66
+ {
67
+ id: 1,
68
+ name: 'Electronics',
69
+ url: '/categories/electronics',
70
+ imageUrl: '/assets/electronics-category.jpg',
71
+ thumbnailImageUrl: '/assets/electronics-thumb.jpg',
72
+ alt: 'Electronics category',
73
+ imageBorderInfo: {
74
+ homogeneity: true,
75
+ color: {
76
+ r: 255,
77
+ g: 255,
78
+ b: 255,
79
+ a: 1
80
+ }
81
+ }
82
+ },
83
+ {
84
+ id: 2,
85
+ name: 'Clothing',
86
+ url: '/categories/clothing',
87
+ imageUrl: '/assets/clothing-category.jpg',
88
+ thumbnailImageUrl: '/assets/clothing-thumb.jpg',
89
+ alt: 'Clothing category'
90
+ },
91
+ {
92
+ id: 3,
93
+ name: 'Home & Garden',
94
+ url: '/categories/home-garden',
95
+ imageUrl: '/assets/home-garden-category.jpg',
96
+ thumbnailImageUrl: '/assets/home-garden-thumb.jpg',
97
+ alt: 'Home & Garden category'
98
+ }
99
+ ],
100
+ categoryTree: [
101
+ {
102
+ id: 1,
103
+ name: 'Electronics',
104
+ nameTranslated: {
105
+ en: 'Electronics',
106
+ es: 'Electrónicos'
107
+ },
108
+ urlPath: '/electronics',
109
+ enabled: true,
110
+ children: [
111
+ {
112
+ id: 11,
113
+ name: 'Smartphones',
114
+ nameTranslated: {
115
+ en: 'Smartphones',
116
+ es: 'Teléfonos inteligentes'
117
+ },
118
+ urlPath: '/electronics/smartphones',
119
+ enabled: true,
120
+ children: []
121
+ },
122
+ {
123
+ id: 12,
124
+ name: 'Laptops',
125
+ nameTranslated: {
126
+ en: 'Laptops',
127
+ es: 'Portátiles'
128
+ },
129
+ urlPath: '/electronics/laptops',
130
+ enabled: true,
131
+ children: []
132
+ }
133
+ ]
134
+ },
135
+ {
136
+ id: 2,
137
+ name: 'Clothing',
138
+ nameTranslated: {
139
+ en: 'Clothing',
140
+ es: 'Ropa'
141
+ },
142
+ urlPath: '/clothing',
143
+ enabled: true,
144
+ children: [
145
+ {
146
+ id: 21,
147
+ name: 'Men\'s Clothing',
148
+ nameTranslated: {
149
+ en: 'Men\'s Clothing',
150
+ es: 'Ropa para hombres'
151
+ },
152
+ urlPath: '/clothing/mens',
153
+ enabled: true,
154
+ children: []
155
+ },
156
+ {
157
+ id: 22,
158
+ name: 'Women\'s Clothing',
159
+ nameTranslated: {
160
+ en: 'Women\'s Clothing',
161
+ es: 'Ropa para mujeres'
162
+ },
163
+ urlPath: '/clothing/womens',
164
+ enabled: true,
165
+ children: []
166
+ }
167
+ ]
168
+ }
169
+ ]
170
+ }
171
+ };
172
+
173
+ export function getExternalContentMock(): ExternalContentMock {
174
+ return externalContentMock;
175
+ }
@@ -1,8 +1,10 @@
1
1
  /* eslint-disable no-console */
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
  import { loadModule} from "./utils.ts";
4
+ import { getExternalContentMock } from "./mock";
4
5
 
5
6
  let distFolderPath: string | null = null;
7
+ let currentApp: any = null;
6
8
 
7
9
  export function setDistFolderPath(path: string): void {
8
10
  distFolderPath = path;
@@ -28,13 +30,15 @@ interface ColorObject {
28
30
  }
29
31
 
30
32
  function hexToColorObject(hex: string): ColorObject {
31
- const match = /^#?([0-9a-fA-F]{6})$/.exec(hex);
33
+ // Support both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex colors
34
+ const match = /^#?([0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$/.exec(hex);
32
35
  if (!match) throw new Error("Invalid hex color format");
33
36
  const cleanHex = match[1].toLowerCase();
34
37
  const r = parseInt(cleanHex.substring(0, 2), 16);
35
38
  const g = parseInt(cleanHex.substring(2, 4), 16);
36
39
  const b = parseInt(cleanHex.substring(4, 6), 16);
37
- const a = 255;
40
+ // Extract alpha if present (8-digit hex), otherwise default to 255 (fully opaque)
41
+ const a = cleanHex.length === 8 ? parseInt(cleanHex.substring(6, 8), 16) : 255;
38
42
  const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
39
43
  const max = Math.max(rNorm, gNorm, bNorm), min = Math.min(rNorm, gNorm, bNorm);
40
44
  const delta = max - min;
@@ -50,15 +54,15 @@ function hexToColorObject(hex: string): ColorObject {
50
54
  if (h < 0) h += 360;
51
55
  }
52
56
  return {
53
- hex: `#${cleanHex}${a.toString(16).padStart(2, '0')}`,
57
+ hex: `#${cleanHex.substring(0, 6)}${a.toString(16).padStart(2, '0')}`,
54
58
  hsl: { h: Math.round(h), s: +(s * 100).toFixed(1), l: +(l * 100).toFixed(1) },
55
- rgba: { r, g, b, a: 1 },
59
+ rgba: { r, g, b, a: +(a / 255).toFixed(2) },
56
60
  };
57
61
  }
58
62
 
59
63
  function updateHexColors(obj: any): any {
60
- // Matches either 3-digit (#RGB) or 6-digit (#RRGGBB) hex
61
- const hexRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
64
+ // Matches 3-digit (#RGB), 6-digit (#RRGGBB), or 8-digit (#RRGGBBAA) hex
65
+ const hexRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
62
66
 
63
67
  function recurse(value: any): any {
64
68
  if (Array.isArray(value)) return value.map(recurse);
@@ -92,6 +96,77 @@ const replaceGlobalFont = (obj: any, font: string): void => {
92
96
  });
93
97
  };
94
98
 
99
+ function createBackgroundStructure(type: 'solid' | 'gradient', config: any): any {
100
+ return {
101
+ background: {
102
+ background: {
103
+ type,
104
+ ...config,
105
+ },
106
+ },
107
+ };
108
+ }
109
+
110
+ // Helper function to create default gray background
111
+ function createDefaultBackground(): any {
112
+ const defaultGrayColor = hexToColorObject('#F9F9F9');
113
+ return createBackgroundStructure('solid', {
114
+ solid: { color: defaultGrayColor },
115
+ color: 'global.color.background',
116
+ });
117
+ }
118
+
119
+ // Helper function to check if color is global reference
120
+ function isGlobalColor(color: string): boolean {
121
+ return typeof color === 'string' && color.startsWith('global.');
122
+ }
123
+
124
+ function createBackgroundDesign(showcaseBackground: any): any {
125
+ // No background or invalid background
126
+ if (!showcaseBackground) {
127
+ return createDefaultBackground();
128
+ }
129
+
130
+ // Handle gradient backgrounds
131
+ if (showcaseBackground.style === 'GRADIENT' && Array.isArray(showcaseBackground.color)) {
132
+ const [fromColor, toColor] = showcaseBackground.color;
133
+
134
+ // If either color is global, use default
135
+ if (isGlobalColor(fromColor) || isGlobalColor(toColor)) {
136
+ return createDefaultBackground();
137
+ }
138
+
139
+ // Create gradient background
140
+ return createBackgroundStructure('gradient', {
141
+ solid: { color: hexToColorObject(fromColor) },
142
+ gradient: {
143
+ fromColor: hexToColorObject(fromColor),
144
+ toColor: hexToColorObject(toColor),
145
+ },
146
+ color: `gradient(${fromColor}, ${toColor})`,
147
+ });
148
+ }
149
+
150
+ // Handle solid color backgrounds
151
+ const solidColor = showcaseBackground.color;
152
+
153
+ // Global color reference
154
+ if (isGlobalColor(solidColor)) {
155
+ return createDefaultBackground();
156
+ }
157
+
158
+ // Specific hex color
159
+ if (typeof solidColor === 'string') {
160
+ return createBackgroundStructure('solid', {
161
+ solid: { color: hexToColorObject(solidColor) },
162
+ color: solidColor,
163
+ });
164
+ }
165
+
166
+ // Fallback to default
167
+ return createDefaultBackground();
168
+ }
169
+
95
170
  function overrideSettingsFromShowcase(content: any, showcases: any): any {
96
171
  return Object.fromEntries(Object.entries(content).map(([k, v]) => [k, showcases[k] ?? v]));
97
172
  }
@@ -256,28 +331,14 @@ export async function renderShowcase(sectionName: string, showcaseId: string): P
256
331
  const showcase = await loadModule(`${distFolderPath}/sections/${sectionName}/js/showcases/${showcaseId}.mjs`);
257
332
  const design = await loadModule(`${distFolderPath}/sections/${sectionName}/js/settings/design.mjs`);
258
333
 
259
- const { mount } = client.default.init();
334
+ // Get showcase background and create background design
335
+ const showcaseBackground = showcase.default?.design?.background;
336
+ const backgroundDesign = createBackgroundDesign(showcaseBackground);
260
337
 
261
338
  const ovveridenDesign = designTransformer(design.default, showcase.default.design || {});
262
339
 
263
340
  loadSectionCss(sectionName);
264
341
 
265
- const backgroundDesign = {
266
- background: {
267
- background: {
268
- type: 'solid',
269
- solid: {
270
- color: {
271
- raw: '#F9F9F9',
272
- hex: '#F9F9F9',
273
- rgba: { r: 19, g: 19, b: 19, a: 1.0 },
274
- },
275
- },
276
- color: 'global.color.background',
277
- },
278
- },
279
- };
280
-
281
342
  const overriddenContent = getContentToRender(
282
343
  content.default,
283
344
  showcase.default.content || {},
@@ -286,15 +347,32 @@ export async function renderShowcase(sectionName: string, showcaseId: string): P
286
347
  sectionName
287
348
  );
288
349
 
289
- mount('#app', {
350
+ // Get external content mock (can be customized in compiled mock.js)
351
+ const externalContentMock = getExternalContentMock();
352
+
353
+ // Ensure background design always overrides any existing background
354
+ const finalDesign = { ...ovveridenDesign };
355
+ finalDesign.background = backgroundDesign.background;
356
+
357
+ const state = {
290
358
  context: {
291
359
  globalDesign: { color: 'global.color.background' },
292
360
  },
293
361
  data: {
294
362
  content: overriddenContent,
295
- design: { ...ovveridenDesign, ...backgroundDesign },
363
+ design: finalDesign,
296
364
  defaults: {},
297
365
  background: {},
366
+ externalContent: externalContentMock,
298
367
  },
299
- });
368
+ };
369
+
370
+ // If app is already mounted, update it; otherwise mount it
371
+ if (currentApp) {
372
+ currentApp.update(state);
373
+ } else {
374
+ const { mount, update, unmount } = client.default.init();
375
+ currentApp = { update, unmount };
376
+ mount('#app', state);
377
+ }
300
378
  }
@@ -1,9 +1,11 @@
1
- import { defineConfig } from 'vite';
1
+ import {defineConfig} from 'vite';
2
+ import path from "path";
3
+ import fs from "fs";
2
4
 
3
5
  export default defineConfig({
4
6
  root: '.',
5
7
  server: {
6
- fs: { strict: false },
8
+ fs: {strict: false},
7
9
  },
8
10
  plugins: [
9
11
  {
@@ -19,13 +21,53 @@ export default defineConfig({
19
21
  {
20
22
  name: 'no-vite-cache',
21
23
  configureServer(server) {
22
- // invalidate caches so that after build when user refreshes the page, the latest .mjs files are fetched
24
+ // use process.cwd() for analytics path
25
+ const analyticsDir = path.resolve(process.cwd(), 'preview', 'shared')
26
+ const analyticsFile = path.resolve(analyticsDir, 'analytics.json')
27
+
28
+ // ensure analytics folder & file exist
29
+ fs.mkdirSync(analyticsDir, {recursive: true})
30
+ if (!fs.existsSync(analyticsFile)) {
31
+ fs.writeFileSync(analyticsFile, JSON.stringify({}, null, 2), 'utf-8')
32
+ }
33
+
34
+ // helper to bump and persist counter for a given section
35
+ function bumpSectionCount(section) {
36
+ const raw = fs.readFileSync(analyticsFile, 'utf-8')
37
+ const data = JSON.parse(raw)
38
+ const next = (data[section] || 0) + 1
39
+ data[section] = next
40
+ fs.writeFileSync(analyticsFile, JSON.stringify(data, null, 2), 'utf-8')
41
+ return next
42
+ }
43
+
23
44
  server.middlewares.use((req, res, next) => {
24
- if (req.url?.includes('/sections/')) {
25
- server.moduleGraph.invalidateAll();
45
+ const url = req.url || ''
46
+
47
+ // cache-bust for any /sections/ URL
48
+ if (url.includes('/sections/')) {
49
+ server.moduleGraph.invalidateAll()
50
+ return next()
51
+ }
52
+
53
+ if (url.startsWith('/chosen-section')) {
54
+ // extract "sectionName" from "/chosen-section/<sectionName>"
55
+
56
+ const sectionName = req.url.replace(/\/$/, '').split('/').filter(Boolean).pop()
57
+ const newCount = bumpSectionCount(sectionName)
58
+
59
+ res.statusCode = 200
60
+ res.setHeader('Content-Type', 'application/json')
61
+ return res.end(JSON.stringify({
62
+ section: sectionName,
63
+ count: newCount
64
+ }))
65
+ }
66
+
67
+ // all other requests
68
+ next()
26
69
  }
27
- next();
28
- });
70
+ );
29
71
  },
30
72
  },
31
73
  ],
@@ -0,0 +1,22 @@
1
+ export default {
2
+ metadata: {
3
+ name: 'Reference Template — Apparel',
4
+ description: 'This is a reference template geared towards apparel merchants to aid development and act as a starting point for your custom template.',
5
+ preview_url: 'https://reference-template-apparel.company.site/',
6
+ cover_image: {
7
+ set: {
8
+ ORIGINAL: {
9
+ url: 'reference_template_apparel_cover_image.jpg',
10
+ },
11
+ },
12
+ },
13
+ },
14
+ header: {
15
+ type: 'default',
16
+ id: 'header',
17
+ },
18
+ footer: {
19
+ type: 'default',
20
+ id: 'footer',
21
+ },
22
+ };
@@ -0,0 +1,10 @@
1
+ import { StorePageConfiguration } from '@lightspeed/crane';
2
+
3
+ export default {
4
+ sections: [
5
+ {
6
+ type: 'store',
7
+ id: undefined,
8
+ },
9
+ ],
10
+ } satisfies StorePageConfiguration;
@@ -0,0 +1,10 @@
1
+ import { StorePageConfiguration } from '@lightspeed/crane';
2
+
3
+ export default {
4
+ sections: [
5
+ {
6
+ type: 'store',
7
+ id: undefined,
8
+ },
9
+ ],
10
+ } satisfies StorePageConfiguration;
@@ -0,0 +1,24 @@
1
+ export default {
2
+ sections: [
3
+ {
4
+ type: 'custom',
5
+ id: 'intro-slider',
6
+ showcase_id: '1',
7
+ },
8
+ {
9
+ type: 'custom',
10
+ id: 'tag-lines',
11
+ showcase_id: '1',
12
+ },
13
+ {
14
+ type: 'custom',
15
+ id: 'about-us',
16
+ showcase_id: '1',
17
+ },
18
+ {
19
+ type: 'custom',
20
+ id: 'example-section',
21
+ showcase_id: '1',
22
+ },
23
+ ],
24
+ };
@@ -0,0 +1,10 @@
1
+ import { StorePageConfiguration } from '@lightspeed/crane';
2
+
3
+ export default {
4
+ sections: [
5
+ {
6
+ type: 'store',
7
+ id: undefined,
8
+ },
9
+ ],
10
+ } satisfies StorePageConfiguration;
@@ -0,0 +1,22 @@
1
+ export default {
2
+ metadata: {
3
+ name: 'Reference Template — Bike',
4
+ description: 'This is a reference template geared towards bike shops to aid development and act as a starting point for your custom template.',
5
+ preview_url: 'https://reference-template-bike.company.site/',
6
+ cover_image: {
7
+ set: {
8
+ ORIGINAL: {
9
+ url: 'reference_template_bike_cover_image.jpg',
10
+ },
11
+ },
12
+ },
13
+ },
14
+ header: {
15
+ type: 'default',
16
+ id: 'header',
17
+ },
18
+ footer: {
19
+ type: 'default',
20
+ id: 'footer',
21
+ },
22
+ };
@@ -0,0 +1,10 @@
1
+ import { StorePageConfiguration } from '@lightspeed/crane';
2
+
3
+ export default {
4
+ sections: [
5
+ {
6
+ type: 'store',
7
+ id: undefined,
8
+ },
9
+ ],
10
+ } satisfies StorePageConfiguration;
@@ -0,0 +1,10 @@
1
+ import { StorePageConfiguration } from '@lightspeed/crane';
2
+
3
+ export default {
4
+ sections: [
5
+ {
6
+ type: 'store',
7
+ id: undefined,
8
+ },
9
+ ],
10
+ } satisfies StorePageConfiguration;
@@ -0,0 +1,24 @@
1
+ export default {
2
+ sections: [
3
+ {
4
+ type: 'custom',
5
+ id: 'intro-slider',
6
+ showcase_id: '2',
7
+ },
8
+ {
9
+ type: 'custom',
10
+ id: 'example-section',
11
+ showcase_id: '3',
12
+ },
13
+ {
14
+ type: 'custom',
15
+ id: 'tag-lines',
16
+ showcase_id: '2',
17
+ },
18
+ {
19
+ type: 'custom',
20
+ id: 'about-us',
21
+ showcase_id: '2',
22
+ },
23
+ ],
24
+ };
@@ -0,0 +1,10 @@
1
+ import { StorePageConfiguration } from '@lightspeed/crane';
2
+
3
+ export default {
4
+ sections: [
5
+ {
6
+ type: 'store',
7
+ id: undefined,
8
+ },
9
+ ],
10
+ } satisfies StorePageConfiguration;