@deneb-ui/cli 2.0.108 → 2.0.109
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 +2 -2
- package/src/common/fivora-site-data.ts +103 -21
- package/src/common/template-editor-schema.ts +288 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deneb-ui/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.109",
|
|
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.
|
|
52
|
+
"@deneb-ui/core": "^2.0.109",
|
|
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
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
if (
|
|
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 =
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
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
|
|
2306
|
-
|
|
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
|
+
}
|