@deneb-ui/cli 2.0.108 → 2.0.110

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.108",
3
+ "version": "2.0.110",
4
4
  "description": "Official DENEB CLI — scaffold, convert, validate, and package Fivora-ready storefront templates.",
5
5
  "bin": {
6
6
  "deneb": "bin/index.js",
@@ -49,7 +49,7 @@
49
49
  "license": "MIT",
50
50
  "dependencies": {
51
51
  "@babel/parser": "^7.28.0",
52
- "@deneb-ui/core": "^2.0.108",
52
+ "@deneb-ui/core": "^2.0.110",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -52,10 +52,10 @@ export function applyLinkedProjectMedia(
52
52
  for (const item of ordered) {
53
53
  if (item.linkedContentPath?.trim()) {
54
54
  const linkedPath = item.linkedContentPath.trim();
55
- // A saved value, including an explicit empty string, is authoritative.
56
- // Media linkage is only a compatibility fallback for older projects
57
- // whose content object never stored the linked field at all.
58
- if (readContentPath(originalContent, linkedPath) !== undefined) {
55
+ const existing = readContentPath(originalContent, linkedPath);
56
+ // An explicit empty string (cleared by user) is authoritative.
57
+ // But undefined or null (uninitialized DB/template default) must be backfilled.
58
+ if (existing !== undefined && existing !== null) {
59
59
  continue;
60
60
  }
61
61
  result = setContentPath(result, linkedPath, item.fileUrl);
@@ -70,27 +70,110 @@ export function applyLinkedProjectMedia(
70
70
  : null;
71
71
  if (!collection || !item.entityId) continue;
72
72
 
73
- const entries = result[collection];
74
- if (!Array.isArray(entries)) continue;
75
- const index = entries.findIndex(
76
- (entry) =>
77
- isRecord(entry) &&
78
- (entry.id === item.entityId ||
79
- entry.productId === item.entityId ||
80
- entry.serviceId === item.entityId),
81
- );
82
- if (index >= 0) {
83
- result = setContentPath(
84
- result,
85
- `${collection}[${index}].imageUrl`,
86
- item.fileUrl,
73
+ const candidateCollections: Array<{ key: string; entries: unknown[] }> = [];
74
+ if (Array.isArray(result[collection])) {
75
+ candidateCollections.push({
76
+ key: collection,
77
+ entries: result[collection] as unknown[],
78
+ });
79
+ }
80
+ const shop = isRecord(result.shop)
81
+ ? (result.shop as Record<string, unknown>)
82
+ : null;
83
+ if (collection === 'products' && shop && Array.isArray(shop.products)) {
84
+ candidateCollections.push({
85
+ key: 'shop.products',
86
+ entries: shop.products,
87
+ });
88
+ }
89
+ const home = isRecord(result.home)
90
+ ? (result.home as Record<string, unknown>)
91
+ : null;
92
+ if (home) {
93
+ if (collection === 'products') {
94
+ if (Array.isArray(home.products)) {
95
+ candidateCollections.push({
96
+ key: 'home.products',
97
+ entries: home.products,
98
+ });
99
+ }
100
+ if (Array.isArray(home.featuredProducts)) {
101
+ candidateCollections.push({
102
+ key: 'home.featuredProducts',
103
+ entries: home.featuredProducts,
104
+ });
105
+ }
106
+ } else if (collection === 'services') {
107
+ if (Array.isArray(home.services)) {
108
+ candidateCollections.push({
109
+ key: 'home.services',
110
+ entries: home.services,
111
+ });
112
+ }
113
+ if (Array.isArray(home.featuredServices)) {
114
+ candidateCollections.push({
115
+ key: 'home.featuredServices',
116
+ entries: home.featuredServices,
117
+ });
118
+ }
119
+ }
120
+ }
121
+
122
+ // Discover any collection dynamically anywhere in result containing this entity
123
+ const foundKeys = new Set(candidateCollections.map((c) => c.key));
124
+ const findCollectionsWithEntity = (obj: unknown, prefix: string) => {
125
+ if (!isRecord(obj)) return;
126
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
127
+ const path = prefix ? `${prefix}.${k}` : k;
128
+ if (Array.isArray(v)) {
129
+ if (!foundKeys.has(path)) {
130
+ const hasMatch = v.some(
131
+ (entry) =>
132
+ isRecord(entry) &&
133
+ (entry.id === item.entityId ||
134
+ entry.productId === item.entityId ||
135
+ entry.serviceId === item.entityId),
136
+ );
137
+ if (hasMatch) {
138
+ candidateCollections.push({ key: path, entries: v });
139
+ foundKeys.add(path);
140
+ }
141
+ }
142
+ } else if (isRecord(v) && prefix.split('.').length < 3) {
143
+ findCollectionsWithEntity(v, path);
144
+ }
145
+ }
146
+ };
147
+ findCollectionsWithEntity(result, '');
148
+
149
+ for (const { key, entries } of candidateCollections) {
150
+ const index = entries.findIndex(
151
+ (entry) =>
152
+ isRecord(entry) &&
153
+ (entry.id === item.entityId ||
154
+ entry.productId === item.entityId ||
155
+ entry.serviceId === item.entityId),
87
156
  );
157
+ if (index >= 0) {
158
+ result = setContentPath(
159
+ result,
160
+ `${key}[${index}].imageUrl`,
161
+ item.fileUrl,
162
+ );
163
+ if (isRecord(entries[index]) && 'image' in entries[index]) {
164
+ result = setContentPath(
165
+ result,
166
+ `${key}[${index}].image`,
167
+ item.fileUrl,
168
+ );
169
+ }
170
+ }
88
171
  }
89
172
  }
90
173
  return result;
91
174
  }
92
175
 
93
- /** Backfills linked content-image media only when an older record lacks the field. */
176
+ /** Backfills linked content-image media only when an older record lacks the field or is null. */
94
177
  export function applyLinkedContentImages(
95
178
  content: Record<string, unknown> | null,
96
179
  items: ProjectMediaItem[],
@@ -107,7 +190,7 @@ export function applyLinkedContentImages(
107
190
  for (const item of ordered) {
108
191
  const linkedPath = item.linkedContentPath!.trim();
109
192
  const existing = readContentPath(originalContent, linkedPath);
110
- if (existing !== undefined) {
193
+ if (existing !== undefined && existing !== null) {
111
194
  continue;
112
195
  }
113
196
  result = setContentPath(result, linkedPath, item.fileUrl);
@@ -174,7 +257,6 @@ export function buildTemplateValidationSiteData(
174
257
  structure: null,
175
258
  },
176
259
  requirements: {
177
- websiteType: '',
178
260
  mainPurpose: '',
179
261
  targetCustomers: '',
180
262
  preferredLanguage: '',
@@ -76,6 +76,11 @@ export type TemplateEditorValidationIssue = {
76
76
  message: string;
77
77
  };
78
78
 
79
+ export type CatalogSchemaListCandidate = {
80
+ path: string;
81
+ score: number;
82
+ };
83
+
79
84
  export type DuplicateTemplateEditorPath = {
80
85
  path: string;
81
86
  firstKind: 'field' | 'list';
@@ -188,13 +193,65 @@ export function normalizeTemplateEditorSchema(
188
193
  return null;
189
194
  }
190
195
 
191
- return {
196
+ return makeDatabaseProductListsUnbounded({
192
197
  version:
193
198
  typeof candidate.version === 'number' &&
194
199
  Number.isFinite(candidate.version)
195
200
  ? candidate.version
196
201
  : 1,
197
202
  sections,
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Products are persisted in shop_products and are intentionally unbounded.
208
+ * Ignore maxItems from older stored manifests so existing projects do not keep
209
+ * an obsolete UI-only catalog cap after the template package is updated.
210
+ */
211
+ export function makeDatabaseProductListsUnbounded(
212
+ schema: TemplateEditorSchema,
213
+ ): TemplateEditorSchema {
214
+ const isProductCollectionPath = (path: string) =>
215
+ /(^|\.)(products?|items?|catalog|menu|dishes?|goods|featuredproducts?|store|foods?|drinks?)$/i.test(
216
+ path.trim(),
217
+ );
218
+
219
+ const mapField = (
220
+ field: TemplateEditorField,
221
+ parentPath: string,
222
+ ): TemplateEditorField => {
223
+ const path = appendPath(parentPath, field.key);
224
+ if (field.type === 'object') {
225
+ return {
226
+ ...field,
227
+ fields: field.fields.map((child) => mapField(child, path)),
228
+ };
229
+ }
230
+ if (field.type === 'list') {
231
+ const mappedFields = field.fields?.map((child) =>
232
+ mapField(child, `${path}[*]`),
233
+ );
234
+ if (isProductCollectionPath(path)) {
235
+ const { maxItems: _obsoleteMaximum, ...unbounded } = field;
236
+ return { ...unbounded, fields: mappedFields };
237
+ }
238
+ return { ...field, fields: mappedFields };
239
+ }
240
+ return field;
241
+ };
242
+
243
+ return {
244
+ ...schema,
245
+ sections: schema.sections.map((section) => {
246
+ const mappedFields = section.fields?.map((field) =>
247
+ mapField(field, section.path),
248
+ );
249
+ if (section.type === 'list' && isProductCollectionPath(section.path)) {
250
+ const { maxItems: _obsoleteMaximum, ...unbounded } = section;
251
+ return { ...unbounded, fields: mappedFields };
252
+ }
253
+ return { ...section, fields: mappedFields };
254
+ }),
198
255
  };
199
256
  }
200
257
 
@@ -1724,7 +1781,7 @@ function mergePrimitiveItemFields(
1724
1781
  };
1725
1782
  }
1726
1783
 
1727
- function findSchemaSectionAtPath(
1784
+ export function findSchemaSectionAtPath(
1728
1785
  schema: TemplateEditorSchema,
1729
1786
  targetPath: string,
1730
1787
  ): TemplateEditorSection | null {
@@ -1763,7 +1820,11 @@ function findSchemaSectionAtPath(
1763
1820
  if (field.type === 'list') {
1764
1821
  const itemPath = `${normalizeEditorPathPattern(path)}[*]`;
1765
1822
  for (const child of field.fields ?? []) {
1766
- const found = visitField(child, appendPath(itemPath, child.key), shopOwner);
1823
+ const found = visitField(
1824
+ child,
1825
+ appendPath(itemPath, child.key),
1826
+ shopOwner,
1827
+ );
1767
1828
  if (found) return found;
1768
1829
  }
1769
1830
  }
@@ -1802,6 +1863,130 @@ function findSchemaSectionAtPath(
1802
1863
  return null;
1803
1864
  }
1804
1865
 
1866
+ function collectSchemaListCandidates(
1867
+ schema: TemplateEditorSchema,
1868
+ ): Array<{ path: string; node: TemplateEditorSection }> {
1869
+ const candidates: Array<{ path: string; node: TemplateEditorSection }> = [];
1870
+
1871
+ const asSection = (
1872
+ field: TemplateEditorField,
1873
+ path: string,
1874
+ owner: TemplateEditorSection,
1875
+ ): TemplateEditorSection => {
1876
+ const { key, ...node } = field;
1877
+ void key;
1878
+ return {
1879
+ ...node,
1880
+ id: owner.id,
1881
+ path,
1882
+ pageKey: owner.pageKey,
1883
+ };
1884
+ };
1885
+
1886
+ const visitObjectFields = (
1887
+ fields: TemplateEditorField[],
1888
+ parentPath: string,
1889
+ owner: TemplateEditorSection,
1890
+ ) => {
1891
+ for (const field of fields) {
1892
+ const path = appendPath(parentPath, field.key);
1893
+ if (field.type === 'list') {
1894
+ candidates.push({ path, node: asSection(field, path, owner) });
1895
+ continue;
1896
+ }
1897
+ if (field.type === 'object') {
1898
+ visitObjectFields(field.fields, path, owner);
1899
+ }
1900
+ }
1901
+ };
1902
+
1903
+ for (const section of schema.sections) {
1904
+ if (section.type === 'list') {
1905
+ candidates.push({ path: section.path, node: section });
1906
+ } else if (section.type === 'object') {
1907
+ visitObjectFields(section.fields ?? [], section.path, section);
1908
+ }
1909
+ }
1910
+
1911
+ return candidates;
1912
+ }
1913
+
1914
+ function catalogListScore(
1915
+ path: string,
1916
+ node: TemplateEditorSection,
1917
+ kind: 'products' | 'services',
1918
+ ) {
1919
+ const segments = path
1920
+ .replace(/\[\*\]/g, '')
1921
+ .split('.')
1922
+ .map((part) => part.toLowerCase())
1923
+ .filter(Boolean);
1924
+ const leaf = segments.at(-1) ?? '';
1925
+ const parent = segments.at(-2) ?? '';
1926
+ const fields = node.fields ?? [];
1927
+ const hasPrice = fields.some((field) =>
1928
+ /^(price|currentprice|amount|cost|rate|saleprice|unitprice|productprice)$/i.test(
1929
+ field.key,
1930
+ ),
1931
+ );
1932
+ const serviceLeaf =
1933
+ /^(services?|treatments?|offerings?|packages?|plans?)$/.test(leaf);
1934
+
1935
+ if (kind === 'services') {
1936
+ if (!serviceLeaf) return 0;
1937
+ return /^(shop|catalog|menu|store|services?)$/.test(parent) ? 120 : 100;
1938
+ }
1939
+
1940
+ // A priced service list is still a service list, never the product catalog.
1941
+ if (serviceLeaf) return 0;
1942
+
1943
+ if (/^featuredproducts?$/.test(leaf)) return 90;
1944
+ if (/^products?$/.test(leaf)) {
1945
+ if (!parent) return 115;
1946
+ if (/^(shop|catalog|menu|store)$/.test(parent)) return 120;
1947
+ if (parent === 'home') return 100;
1948
+ return 80;
1949
+ }
1950
+ if (/^(dishes?|goods|foods?|drinks?)$/.test(leaf)) {
1951
+ return /^(shop|catalog|menu|store)$/.test(parent) ? 110 : 70;
1952
+ }
1953
+ if (/^items?$/.test(leaf)) {
1954
+ if (!parent || /^(shop|catalog|menu|store)$/.test(parent)) return 100;
1955
+ // Generic promotional lists such as seasonal.items are only a last-resort
1956
+ // catalog candidate. They must not outrank an explicit shop.products list.
1957
+ return hasPrice ? 10 : 0;
1958
+ }
1959
+ if (/^(catalog|menu|store)$/.test(leaf)) return 75;
1960
+ return hasPrice ? 10 : 0;
1961
+ }
1962
+
1963
+ /**
1964
+ * Rank database-backed catalog collections declared by a template schema.
1965
+ * Explicit catalog paths beat generic priced lists such as seasonal.items.
1966
+ */
1967
+ export function getCatalogSchemaListCandidates(
1968
+ schema: TemplateEditorSchema | null | undefined,
1969
+ kind: 'products' | 'services',
1970
+ ): CatalogSchemaListCandidate[] {
1971
+ if (!schema) return [];
1972
+ return collectSchemaListCandidates(schema)
1973
+ .map(({ path, node }, order) => ({
1974
+ path,
1975
+ score: catalogListScore(path, node, kind),
1976
+ order,
1977
+ }))
1978
+ .filter((candidate) => candidate.score > 0)
1979
+ .sort((left, right) => right.score - left.score || left.order - right.order)
1980
+ .map(({ path, score }) => ({ path, score }));
1981
+ }
1982
+
1983
+ export function getPreferredCatalogSchemaListPath(
1984
+ schema: TemplateEditorSchema | null | undefined,
1985
+ kind: 'products' | 'services',
1986
+ ) {
1987
+ return getCatalogSchemaListCandidates(schema, kind)[0]?.path ?? null;
1988
+ }
1989
+
1805
1990
  function isDescendantEditorPath(candidate: string, parent: string) {
1806
1991
  const normalizedCandidate = normalizeEditorPathPattern(candidate);
1807
1992
  const normalizedParent = normalizeEditorPathPattern(parent);
@@ -2143,7 +2328,7 @@ function hasRequiredNodes(node: TemplateEditorSection | TemplateEditorField) {
2143
2328
  return false;
2144
2329
  }
2145
2330
 
2146
- function getValueByPath(content: Record<string, unknown>, path: string) {
2331
+ export function getValueByPath(content: Record<string, unknown>, path: string) {
2147
2332
  const parts = path
2148
2333
  .split('.')
2149
2334
  .map((part) => part.trim())
@@ -2302,8 +2487,12 @@ export function coercePrimitiveValue(
2302
2487
  if (trimmed === '') {
2303
2488
  return defaultValue !== undefined ? defaultValue : null;
2304
2489
  }
2305
- const num = Number(trimmed);
2306
- if (!Number.isNaN(num)) {
2490
+ const cleaned = trimmed.replace(/[^0-9.-]/g, '');
2491
+ const num =
2492
+ cleaned !== '' && cleaned !== '-' && cleaned !== '.'
2493
+ ? Number(cleaned)
2494
+ : Number.NaN;
2495
+ if (!Number.isNaN(num) && Number.isFinite(num)) {
2307
2496
  return num;
2308
2497
  }
2309
2498
  }
@@ -2413,11 +2602,7 @@ function coerceNodeValue(
2413
2602
  });
2414
2603
  }
2415
2604
 
2416
- return coercePrimitiveValue(
2417
- value,
2418
- node.type as PrimitiveType,
2419
- defaultVal,
2420
- );
2605
+ return coercePrimitiveValue(value, node.type as PrimitiveType, defaultVal);
2421
2606
  }
2422
2607
 
2423
2608
  function coerceAtDottedPath(
@@ -2536,3 +2721,95 @@ export function coerceContentTypes(
2536
2721
 
2537
2722
  return result;
2538
2723
  }
2724
+
2725
+ export function writeDottedPath(
2726
+ target: Record<string, unknown>,
2727
+ path: string,
2728
+ value: unknown,
2729
+ ): void {
2730
+ const parts = path
2731
+ .split('.')
2732
+ .map((p) => p.trim())
2733
+ .filter(Boolean);
2734
+ if (parts.length === 0) return;
2735
+ let cursor: Record<string, unknown> = target;
2736
+ for (let i = 0; i < parts.length - 1; i++) {
2737
+ const part = parts[i];
2738
+ if (
2739
+ !cursor[part] ||
2740
+ typeof cursor[part] !== 'object' ||
2741
+ Array.isArray(cursor[part])
2742
+ ) {
2743
+ cursor[part] = {};
2744
+ }
2745
+ cursor = cursor[part] as Record<string, unknown>;
2746
+ }
2747
+ cursor[parts.at(-1)!] = value;
2748
+ }
2749
+
2750
+ /**
2751
+ * Universal schema-aware hydration: writes items into any section or list field
2752
+ * declared in editorSchema that represents products or services, regardless of
2753
+ * developer-chosen naming conventions (e.g. products, shop.products, home.products,
2754
+ * menu.items, catalog.products, store.products).
2755
+ */
2756
+ export function hydrateSchemaListField(
2757
+ content: Record<string, unknown>,
2758
+ schema: TemplateEditorSchema | null | undefined,
2759
+ fieldKeys: string[],
2760
+ items: Record<string, unknown>[],
2761
+ ): void {
2762
+ if (!schema || !Array.isArray(schema.sections)) return;
2763
+
2764
+ const keySet = new Set(fieldKeys.map((k) => k.toLowerCase()));
2765
+ const isProductHydration = keySet.has('products');
2766
+ const isServiceHydration = keySet.has('services');
2767
+ const allowedPaths = new Set(
2768
+ [
2769
+ ...(isProductHydration
2770
+ ? [getPreferredCatalogSchemaListPath(schema, 'products')]
2771
+ : []),
2772
+ ...(isServiceHydration
2773
+ ? [getPreferredCatalogSchemaListPath(schema, 'services')]
2774
+ : []),
2775
+ ].filter((path): path is string => Boolean(path)),
2776
+ );
2777
+
2778
+ for (const section of schema.sections) {
2779
+ const sectionPath = section.path?.trim() || '';
2780
+ if (!sectionPath) continue;
2781
+
2782
+ // 1. Top-level list section
2783
+ if (section.type === 'list') {
2784
+ if (allowedPaths.has(sectionPath)) {
2785
+ const existing = getValueByPath(content, sectionPath);
2786
+ if (
2787
+ existing === undefined ||
2788
+ existing === null ||
2789
+ Array.isArray(existing)
2790
+ ) {
2791
+ writeDottedPath(content, sectionPath, items);
2792
+ }
2793
+ }
2794
+ }
2795
+
2796
+ // 2. Object section with nested list fields
2797
+ if (section.type === 'object' && Array.isArray(section.fields)) {
2798
+ for (const field of section.fields) {
2799
+ if (field.type !== 'list') continue;
2800
+ const fieldKey = (field as { key: string }).key;
2801
+ const fullPath = sectionPath ? `${sectionPath}.${fieldKey}` : fieldKey;
2802
+ if (allowedPaths.has(fullPath)) {
2803
+ const existing = getValueByPath(content, fullPath);
2804
+ if (
2805
+ existing === undefined ||
2806
+ existing === null ||
2807
+ Array.isArray(existing)
2808
+ ) {
2809
+ writeDottedPath(content, fullPath, items);
2810
+ }
2811
+ }
2812
+ }
2813
+ }
2814
+ }
2815
+ }