@thg-altitude/schemaorg 1.0.31 → 1.0.33
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/.nvmrc +1 -1
- package/index.js +4 -2
- package/package.json +1 -1
- package/src/components/CollectionPage.astro +1 -0
- package/src/components/EnhancedProduct.astro +458 -67
- package/src/components/Product.astro +327 -52
- package/src/components/Recipe.astro +175 -0
- package/src/utils/productSchema.js +119 -0
package/.nvmrc
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
22.15.0
|
package/index.js
CHANGED
|
@@ -6,8 +6,9 @@ import Organization from './src/components/Organization.astro'
|
|
|
6
6
|
import ProductCollection from './src/components/ProductCollection.astro'
|
|
7
7
|
import WebSite from './src/components/WebSite.astro'
|
|
8
8
|
import EnhancedProductList from './src/components/EnhancedProductList.astro'
|
|
9
|
+
import Recipe from './src/components/Recipe.astro'
|
|
9
10
|
|
|
10
|
-
export {
|
|
11
|
+
export {
|
|
11
12
|
Product,
|
|
12
13
|
EnhancedProduct,
|
|
13
14
|
Breadcrumb,
|
|
@@ -15,5 +16,6 @@ export {
|
|
|
15
16
|
Organization,
|
|
16
17
|
ProductCollection,
|
|
17
18
|
WebSite,
|
|
18
|
-
EnhancedProductList
|
|
19
|
+
EnhancedProductList,
|
|
20
|
+
Recipe
|
|
19
21
|
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { mapProductSchemaDataEnhanced } from '../utils/productSchema.js';
|
|
3
|
+
|
|
2
4
|
const {
|
|
3
5
|
image,
|
|
4
6
|
sku,
|
|
@@ -23,8 +25,32 @@ const {
|
|
|
23
25
|
streetAddress,
|
|
24
26
|
addressLocality,
|
|
25
27
|
postalCode,
|
|
28
|
+
suggestedAge,
|
|
29
|
+
suggestedGender,
|
|
30
|
+
beauty_targetAge,
|
|
31
|
+
gender,
|
|
26
32
|
} = Astro.props;
|
|
27
33
|
|
|
34
|
+
// Use the enhanced mapping function to extract data from the full product data
|
|
35
|
+
const mappedData = mapProductSchemaDataEnhanced(Astro.props);
|
|
36
|
+
|
|
37
|
+
// Extract suggestedGender and suggestedAge using the comprehensive mapping function
|
|
38
|
+
let extractedSuggestedGender = suggestedGender || mappedData.suggestedGender;
|
|
39
|
+
let extractedSuggestedAge = suggestedAge || mappedData.suggestedAge;
|
|
40
|
+
|
|
41
|
+
// Fallback to the original simple extraction if the mapping didn't find anything
|
|
42
|
+
if (!extractedSuggestedGender && Astro.props.productContent?.propertyNameToValueMap?.gender) {
|
|
43
|
+
extractedSuggestedGender = Array.isArray(Astro.props.productContent.propertyNameToValueMap.gender)
|
|
44
|
+
? Astro.props.productContent.propertyNameToValueMap.gender[0]
|
|
45
|
+
: Astro.props.productContent.propertyNameToValueMap.gender;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!extractedSuggestedAge && Astro.props.productContent?.propertyNameToValueMap?.beauty_targetAge) {
|
|
49
|
+
extractedSuggestedAge = Array.isArray(Astro.props.productContent.propertyNameToValueMap.beauty_targetAge)
|
|
50
|
+
? Astro.props.productContent.propertyNameToValueMap.beauty_targetAge[0]
|
|
51
|
+
: Astro.props.productContent.propertyNameToValueMap.beauty_targetAge;
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
let url;
|
|
29
55
|
let schema = null;
|
|
30
56
|
let schemaError = false;
|
|
@@ -63,49 +89,258 @@ try {
|
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
function generateProductSchema() {
|
|
92
|
+
const hasMultipleVariants = variants && variants.length > 1;
|
|
93
|
+
let variantsArray = [];
|
|
94
|
+
|
|
66
95
|
if (variants && variants.length > 0 && currency) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
if (hasMultipleVariants) {
|
|
97
|
+
// Create individual Product objects for each variant
|
|
98
|
+
variantsArray = variants
|
|
99
|
+
.filter((variant) => variant?.sku && variant?.price?.price?.amount)
|
|
100
|
+
.map((variant) => {
|
|
101
|
+
// Build variant name with attributes from choices in logical order
|
|
102
|
+
let variantName = name;
|
|
103
|
+
const attributes = [];
|
|
104
|
+
|
|
105
|
+
// Extract properties in preferred order: Amount + Weight + Flavor + Size + Color + Material
|
|
106
|
+
if (variant.choices && variant.choices.length > 0) {
|
|
107
|
+
// Amount (only if explicitly marked as Amount, not weight-related)
|
|
108
|
+
const amountChoice = variant.choices.find(c =>
|
|
109
|
+
c.optionKey === 'Amount' &&
|
|
110
|
+
c.title &&
|
|
111
|
+
!c.title.includes('kg') &&
|
|
112
|
+
!c.title.includes('g') &&
|
|
113
|
+
!c.title.includes('lb')
|
|
114
|
+
);
|
|
115
|
+
if (amountChoice && amountChoice.title) attributes.push(amountChoice.title);
|
|
116
|
+
|
|
117
|
+
// Weight (from Weight optionKey OR size containing weight units)
|
|
118
|
+
const weightChoice = variant.choices.find(c =>
|
|
119
|
+
c.optionKey === 'Weight' ||
|
|
120
|
+
(c.optionKey === 'Size' && c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb'))) ||
|
|
121
|
+
(c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb')))
|
|
122
|
+
);
|
|
123
|
+
if (weightChoice && weightChoice.title) attributes.push(weightChoice.title);
|
|
124
|
+
|
|
125
|
+
// Flavor
|
|
126
|
+
const flavorChoice = variant.choices.find(c =>
|
|
127
|
+
c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'
|
|
128
|
+
);
|
|
129
|
+
if (flavorChoice && flavorChoice.title) attributes.push(flavorChoice.title);
|
|
130
|
+
|
|
131
|
+
// Size (excluding weight-related sizes and already processed weight)
|
|
132
|
+
const sizeChoice = variant.choices.find(c =>
|
|
133
|
+
c.optionKey === 'Size' &&
|
|
134
|
+
c.title &&
|
|
135
|
+
!c.title.includes('kg') &&
|
|
136
|
+
!c.title.includes('g') &&
|
|
137
|
+
!c.title.includes('lb') &&
|
|
138
|
+
c !== weightChoice // Don't duplicate if already used as weight
|
|
139
|
+
);
|
|
140
|
+
if (sizeChoice && sizeChoice.title) attributes.push(sizeChoice.title);
|
|
141
|
+
|
|
142
|
+
// Color
|
|
143
|
+
const colorChoice = variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour);
|
|
144
|
+
if (colorChoice) {
|
|
145
|
+
if (colorChoice.colour) attributes.push(colorChoice.colour);
|
|
146
|
+
else if (colorChoice.title) attributes.push(colorChoice.title);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Material
|
|
150
|
+
const materialChoice = variant.choices.find(c => c.optionKey === 'Material');
|
|
151
|
+
if (materialChoice && materialChoice.title) attributes.push(materialChoice.title);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (attributes.length > 0) {
|
|
155
|
+
variantName = `${name} ${attributes.join(' ')}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"@type": "Product",
|
|
160
|
+
sku: variant.sku.toString(),
|
|
161
|
+
name: variantName,
|
|
162
|
+
...(description && { description }),
|
|
163
|
+
...(imageUrl && { image: imageUrl }),
|
|
164
|
+
// Extract variant-specific properties from choices using correct schema.org properties
|
|
165
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb')))) && {
|
|
166
|
+
weight: variant.choices.find(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb'))))?.title
|
|
167
|
+
}),
|
|
168
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb')) && {
|
|
169
|
+
size: variant.choices.find(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb'))?.title
|
|
170
|
+
}),
|
|
171
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour) && {
|
|
172
|
+
color: variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour)?.colour ||
|
|
173
|
+
variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour')?.title
|
|
174
|
+
}),
|
|
175
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Material') && {
|
|
176
|
+
material: variant.choices.find(c => c.optionKey === 'Material')?.title
|
|
177
|
+
}),
|
|
178
|
+
// Handle non-standard properties (amount, flavor) as additionalProperty
|
|
179
|
+
...(() => {
|
|
180
|
+
const additionalProperties = [];
|
|
181
|
+
|
|
182
|
+
// Amount
|
|
183
|
+
const amountChoice = variant.choices?.find(c => c.optionKey === 'Amount');
|
|
184
|
+
if (amountChoice?.title) {
|
|
185
|
+
additionalProperties.push({
|
|
186
|
+
"@type": "PropertyValue",
|
|
187
|
+
"name": "amount",
|
|
188
|
+
"value": amountChoice.title
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Flavor
|
|
193
|
+
const flavorChoice = variant.choices?.find(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste');
|
|
194
|
+
if (flavorChoice?.title) {
|
|
195
|
+
additionalProperties.push({
|
|
196
|
+
"@type": "PropertyValue",
|
|
197
|
+
"name": "flavor",
|
|
198
|
+
"value": flavorChoice.title
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return additionalProperties.length > 0 ? { additionalProperty: additionalProperties } : {};
|
|
203
|
+
})(),
|
|
204
|
+
...(variant?.barcode && { gtin13: variant.barcode.toString() }),
|
|
205
|
+
offers: {
|
|
206
|
+
"@type": "Offer",
|
|
207
|
+
url: `${pageUrl}?variation=${variant?.sku}`,
|
|
208
|
+
price: variant.price.price.amount,
|
|
209
|
+
priceCurrency: currency,
|
|
210
|
+
itemCondition: "https://schema.org/NewCondition",
|
|
211
|
+
availability: variant?.inStock
|
|
212
|
+
? "https://schema.org/InStock"
|
|
213
|
+
: "https://schema.org/OutOfStock",
|
|
214
|
+
...(brand && {
|
|
215
|
+
seller: {
|
|
216
|
+
"@type": "Organization",
|
|
217
|
+
name: brand,
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
priceSpecification: [
|
|
98
221
|
{
|
|
99
222
|
"@type": "UnitPriceSpecification",
|
|
100
223
|
priceCurrency: currency,
|
|
101
|
-
price: variant.price.
|
|
224
|
+
price: variant.price.price.amount,
|
|
102
225
|
valueAddedTaxIncluded: true,
|
|
103
|
-
priceType: "https://schema.org/ListPrice",
|
|
104
226
|
},
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
227
|
+
...(variant?.price?.rrp?.amount
|
|
228
|
+
? [
|
|
229
|
+
{
|
|
230
|
+
"@type": "UnitPriceSpecification",
|
|
231
|
+
priceCurrency: currency,
|
|
232
|
+
price: variant.price.rrp.amount,
|
|
233
|
+
valueAddedTaxIncluded: true,
|
|
234
|
+
priceType: "https://schema.org/StrikethroughPrice",
|
|
235
|
+
},
|
|
236
|
+
]
|
|
237
|
+
: []),
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
} else {
|
|
243
|
+
// Single variant - create offers array as before
|
|
244
|
+
offersArray = variants
|
|
245
|
+
.filter((variant) => variant?.sku && variant?.price?.price?.amount)
|
|
246
|
+
.map((variant) => {
|
|
247
|
+
// Build variant name with attributes from choices in logical order
|
|
248
|
+
let variantName = name;
|
|
249
|
+
const attributes = [];
|
|
250
|
+
|
|
251
|
+
// Extract properties in preferred order: Amount + Weight + Flavor + Size + Color + Material
|
|
252
|
+
if (variant.choices && variant.choices.length > 0) {
|
|
253
|
+
// Amount (only if explicitly marked as Amount, not weight-related)
|
|
254
|
+
const amountChoice = variant.choices.find(c =>
|
|
255
|
+
c.optionKey === 'Amount' &&
|
|
256
|
+
c.title &&
|
|
257
|
+
!c.title.includes('kg') &&
|
|
258
|
+
!c.title.includes('g') &&
|
|
259
|
+
!c.title.includes('lb')
|
|
260
|
+
);
|
|
261
|
+
if (amountChoice && amountChoice.title) attributes.push(amountChoice.title);
|
|
262
|
+
|
|
263
|
+
// Weight (from Weight optionKey OR size containing weight units)
|
|
264
|
+
const weightChoice = variant.choices.find(c =>
|
|
265
|
+
c.optionKey === 'Weight' ||
|
|
266
|
+
(c.optionKey === 'Size' && c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb'))) ||
|
|
267
|
+
(c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb')))
|
|
268
|
+
);
|
|
269
|
+
if (weightChoice && weightChoice.title) attributes.push(weightChoice.title);
|
|
270
|
+
|
|
271
|
+
// Flavor
|
|
272
|
+
const flavorChoice = variant.choices.find(c =>
|
|
273
|
+
c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'
|
|
274
|
+
);
|
|
275
|
+
if (flavorChoice && flavorChoice.title) attributes.push(flavorChoice.title);
|
|
276
|
+
|
|
277
|
+
// Size (excluding weight-related sizes and already processed weight)
|
|
278
|
+
const sizeChoice = variant.choices.find(c =>
|
|
279
|
+
c.optionKey === 'Size' &&
|
|
280
|
+
c.title &&
|
|
281
|
+
!c.title.includes('kg') &&
|
|
282
|
+
!c.title.includes('g') &&
|
|
283
|
+
!c.title.includes('lb') &&
|
|
284
|
+
c !== weightChoice // Don't duplicate if already used as weight
|
|
285
|
+
);
|
|
286
|
+
if (sizeChoice && sizeChoice.title) attributes.push(sizeChoice.title);
|
|
287
|
+
|
|
288
|
+
// Color
|
|
289
|
+
const colorChoice = variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour);
|
|
290
|
+
if (colorChoice) {
|
|
291
|
+
if (colorChoice.colour) attributes.push(colorChoice.colour);
|
|
292
|
+
else if (colorChoice.title) attributes.push(colorChoice.title);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Material
|
|
296
|
+
const materialChoice = variant.choices.find(c => c.optionKey === 'Material');
|
|
297
|
+
if (materialChoice && materialChoice.title) attributes.push(materialChoice.title);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (attributes.length > 0) {
|
|
301
|
+
variantName = `${name} ${attributes.join(' ')}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
"@type": "Offer",
|
|
306
|
+
name: variantName,
|
|
307
|
+
sku: variant.sku.toString(),
|
|
308
|
+
url: pageUrl,
|
|
309
|
+
price: variant.price.price.amount,
|
|
310
|
+
priceCurrency: currency,
|
|
311
|
+
itemCondition: "https://schema.org/NewCondition",
|
|
312
|
+
availability: variant?.inStock
|
|
313
|
+
? "https://schema.org/InStock"
|
|
314
|
+
: "https://schema.org/OutOfStock",
|
|
315
|
+
...(variant?.barcode && { gtin13: variant.barcode.toString() }),
|
|
316
|
+
...(brand && {
|
|
317
|
+
seller: {
|
|
318
|
+
"@type": "Organization",
|
|
319
|
+
name: brand,
|
|
320
|
+
},
|
|
321
|
+
}),
|
|
322
|
+
priceSpecification: [
|
|
323
|
+
{
|
|
324
|
+
"@type": "UnitPriceSpecification",
|
|
325
|
+
priceCurrency: currency,
|
|
326
|
+
price: variant.price.price.amount,
|
|
327
|
+
valueAddedTaxIncluded: true,
|
|
328
|
+
},
|
|
329
|
+
...(variant?.price?.rrp?.amount
|
|
330
|
+
? [
|
|
331
|
+
{
|
|
332
|
+
"@type": "UnitPriceSpecification",
|
|
333
|
+
priceCurrency: currency,
|
|
334
|
+
price: variant.price.rrp.amount,
|
|
335
|
+
valueAddedTaxIncluded: true,
|
|
336
|
+
priceType: "https://schema.org/StrikethroughPrice",
|
|
337
|
+
},
|
|
338
|
+
]
|
|
339
|
+
: []),
|
|
340
|
+
],
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
109
344
|
}
|
|
110
345
|
|
|
111
346
|
const category =
|
|
@@ -116,38 +351,166 @@ function generateProductSchema() {
|
|
|
116
351
|
const reviewsArray = generateReviewsSchema();
|
|
117
352
|
|
|
118
353
|
if (name && sku) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
354
|
+
const hasMultipleVariants = variants && variants.length > 1;
|
|
355
|
+
|
|
356
|
+
if (hasMultipleVariants) {
|
|
357
|
+
// Determine what properties vary between variants
|
|
358
|
+
const variesBy = [];
|
|
359
|
+
|
|
360
|
+
// Check what attributes exist across variants based on choices using correct schema.org properties
|
|
361
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb')))))) {
|
|
362
|
+
variesBy.push("https://schema.org/weight");
|
|
363
|
+
}
|
|
364
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb')))) {
|
|
365
|
+
variesBy.push("https://schema.org/size");
|
|
366
|
+
}
|
|
367
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour))) {
|
|
368
|
+
variesBy.push("https://schema.org/color");
|
|
369
|
+
}
|
|
370
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Material'))) {
|
|
371
|
+
variesBy.push("https://schema.org/material");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// For non-standard properties like amount and flavor, use additionalProperty
|
|
375
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Amount')) ||
|
|
376
|
+
variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'))) {
|
|
377
|
+
variesBy.push("https://schema.org/additionalProperty");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const productGroupSchema = {
|
|
381
|
+
"@type": "ProductGroup",
|
|
382
|
+
"@id": pageUrl,
|
|
383
|
+
url: pageUrl,
|
|
384
|
+
name: name,
|
|
385
|
+
productGroupID: sku.toString(),
|
|
386
|
+
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
387
|
+
...(description && { description }),
|
|
388
|
+
...(imageUrl && { image: imageUrl }),
|
|
389
|
+
...(brand && {
|
|
390
|
+
brand: {
|
|
391
|
+
"@type": "Brand",
|
|
392
|
+
name: brand,
|
|
142
393
|
},
|
|
143
394
|
}),
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
395
|
+
...(variesBy.length > 0 && { variesBy }),
|
|
396
|
+
hasVariant: variantsArray,
|
|
397
|
+
...(reviewsCount > 0 &&
|
|
398
|
+
reviewsAverage && {
|
|
399
|
+
aggregateRating: {
|
|
400
|
+
"@type": "AggregateRating",
|
|
401
|
+
ratingValue: reviewsAverage,
|
|
402
|
+
reviewCount: reviewsCount,
|
|
403
|
+
bestRating: 5,
|
|
404
|
+
worstRating: 1,
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
407
|
+
...(reviewsArray && reviewsArray.length > 0 && { review: reviewsArray }),
|
|
408
|
+
...(category && { category }),
|
|
409
|
+
...(keywords && { keywords }),
|
|
410
|
+
// Handle custom properties as additionalProperty
|
|
411
|
+
...(() => {
|
|
412
|
+
const additionalProperties = [];
|
|
413
|
+
if (extractedSuggestedGender) {
|
|
414
|
+
additionalProperties.push({
|
|
415
|
+
"@type": "PropertyValue",
|
|
416
|
+
"name": "suggestedGender",
|
|
417
|
+
"value": extractedSuggestedGender
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (extractedSuggestedAge) {
|
|
421
|
+
additionalProperties.push({
|
|
422
|
+
"@type": "PropertyValue",
|
|
423
|
+
"name": "suggestedAge",
|
|
424
|
+
"value": extractedSuggestedAge
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (beauty_targetAge) {
|
|
428
|
+
additionalProperties.push({
|
|
429
|
+
"@type": "PropertyValue",
|
|
430
|
+
"name": "targetAge",
|
|
431
|
+
"value": beauty_targetAge
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (gender) {
|
|
435
|
+
additionalProperties.push({
|
|
436
|
+
"@type": "PropertyValue",
|
|
437
|
+
"name": "GenderType",
|
|
438
|
+
"value": gender
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return additionalProperties.length > 0 ? { additionalProperty: additionalProperties } : {};
|
|
442
|
+
})(),
|
|
443
|
+
};
|
|
149
444
|
|
|
150
|
-
|
|
445
|
+
graphArray.push(productGroupSchema);
|
|
446
|
+
} else {
|
|
447
|
+
// Single product schema as before
|
|
448
|
+
const productSchema = {
|
|
449
|
+
"@type": "Product",
|
|
450
|
+
"@id": pageUrl,
|
|
451
|
+
url: pageUrl,
|
|
452
|
+
sku: sku.toString(),
|
|
453
|
+
name: name,
|
|
454
|
+
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
455
|
+
...(description && { description }),
|
|
456
|
+
...(imageUrl && { image: imageUrl }),
|
|
457
|
+
...(brand && {
|
|
458
|
+
brand: {
|
|
459
|
+
"@type": "Brand",
|
|
460
|
+
name: brand,
|
|
461
|
+
},
|
|
462
|
+
}),
|
|
463
|
+
...(reviewsCount > 0 &&
|
|
464
|
+
reviewsAverage && {
|
|
465
|
+
aggregateRating: {
|
|
466
|
+
"@type": "AggregateRating",
|
|
467
|
+
ratingValue: reviewsAverage,
|
|
468
|
+
reviewCount: reviewsCount,
|
|
469
|
+
bestRating: 5,
|
|
470
|
+
worstRating: 1,
|
|
471
|
+
},
|
|
472
|
+
}),
|
|
473
|
+
...(reviewsArray && reviewsArray.length > 0 && { review: reviewsArray }),
|
|
474
|
+
...(category && { category }),
|
|
475
|
+
...(keywords && { keywords }),
|
|
476
|
+
// Handle custom properties as additionalProperty
|
|
477
|
+
...(() => {
|
|
478
|
+
const additionalProperties = [];
|
|
479
|
+
if (extractedSuggestedGender) {
|
|
480
|
+
additionalProperties.push({
|
|
481
|
+
"@type": "PropertyValue",
|
|
482
|
+
"name": "suggestedGender",
|
|
483
|
+
"value": extractedSuggestedGender
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (extractedSuggestedAge) {
|
|
487
|
+
additionalProperties.push({
|
|
488
|
+
"@type": "PropertyValue",
|
|
489
|
+
"name": "suggestedAge",
|
|
490
|
+
"value": extractedSuggestedAge
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (beauty_targetAge) {
|
|
494
|
+
additionalProperties.push({
|
|
495
|
+
"@type": "PropertyValue",
|
|
496
|
+
"name": "beauty_targetAge",
|
|
497
|
+
"value": beauty_targetAge
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
if (gender) {
|
|
501
|
+
additionalProperties.push({
|
|
502
|
+
"@type": "PropertyValue",
|
|
503
|
+
"name": "gender",
|
|
504
|
+
"value": gender
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return additionalProperties.length > 0 ? { additionalProperty: additionalProperties } : {};
|
|
508
|
+
})(),
|
|
509
|
+
...(offersArray.length > 0 && { offers: offersArray }),
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
graphArray.push(productSchema);
|
|
513
|
+
}
|
|
151
514
|
}
|
|
152
515
|
}
|
|
153
516
|
|
|
@@ -281,6 +644,34 @@ function generateReviewsSchema() {
|
|
|
281
644
|
|
|
282
645
|
{
|
|
283
646
|
schema && !schemaError && (
|
|
284
|
-
<script type="application/ld+json" set:html={
|
|
647
|
+
<script type="application/ld+json" set:html={(() => {
|
|
648
|
+
// Clean the schema object by removing undefined, null, and empty values
|
|
649
|
+
function cleanObject(obj) {
|
|
650
|
+
if (obj === null || obj === undefined || obj === '') {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (Array.isArray(obj)) {
|
|
655
|
+
const cleaned = obj.map(cleanObject).filter(item => item !== undefined);
|
|
656
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
660
|
+
const cleaned = {};
|
|
661
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
662
|
+
const cleanedValue = cleanObject(value);
|
|
663
|
+
if (cleanedValue !== undefined) {
|
|
664
|
+
cleaned[key] = cleanedValue;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return obj;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const cleanedSchema = cleanObject(schema);
|
|
674
|
+
return cleanedSchema ? JSON.stringify(cleanedSchema, null, 2) : '{}';
|
|
675
|
+
})()} />
|
|
285
676
|
)
|
|
286
677
|
}
|
|
@@ -1,70 +1,345 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { stripHtml } from '
|
|
2
|
+
import { mapProductSchemaData, stripHtml } from '../utils/productSchema.js';
|
|
3
|
+
|
|
4
|
+
// Use the mapping function to extract data from the full product data
|
|
5
|
+
const mappedData = mapProductSchemaData(Astro.props);
|
|
6
|
+
|
|
7
|
+
// Extract suggestedGender and suggestedAge using the comprehensive mapping function
|
|
8
|
+
let suggestedGender = Astro.props.suggestedGender || mappedData.suggestedGender;
|
|
9
|
+
let suggestedAge = Astro.props.suggestedAge || mappedData.suggestedAge;
|
|
10
|
+
|
|
11
|
+
// Fallback to the original simple extraction if the mapping didn't find anything
|
|
12
|
+
if (!suggestedGender && Astro.props.productContent?.propertyNameToValueMap?.gender) {
|
|
13
|
+
suggestedGender = Array.isArray(Astro.props.productContent.propertyNameToValueMap.gender)
|
|
14
|
+
? Astro.props.productContent.propertyNameToValueMap.gender[0]
|
|
15
|
+
: Astro.props.productContent.propertyNameToValueMap.gender;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!suggestedAge && Astro.props.productContent?.propertyNameToValueMap?.beauty_targetAge) {
|
|
19
|
+
suggestedAge = Array.isArray(Astro.props.productContent.propertyNameToValueMap.beauty_targetAge)
|
|
20
|
+
? Astro.props.productContent.propertyNameToValueMap.beauty_targetAge[0]
|
|
21
|
+
: Astro.props.productContent.propertyNameToValueMap.beauty_targetAge;
|
|
22
|
+
}
|
|
23
|
+
|
|
3
24
|
let url = import.meta.env.DEV
|
|
4
25
|
? Astro.url.origin
|
|
5
26
|
: `${Astro.request.headers.get(
|
|
6
27
|
'X-forwarded-Proto'
|
|
7
28
|
)}://${Astro.request.headers.get('X-forwarded-Host')?.split(', ')[0]}`
|
|
29
|
+
// Check if we have multiple variants to determine if we need ProductGroup
|
|
30
|
+
const hasMultipleVariants = Astro.props.variants && Astro.props.variants.length > 1;
|
|
31
|
+
|
|
32
|
+
let variantsArray = []
|
|
8
33
|
let offersArray = []
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
|
|
35
|
+
if (hasMultipleVariants) {
|
|
36
|
+
// Create individual Product objects for each variant
|
|
37
|
+
if (Array.isArray(Astro.props.variants)) {
|
|
38
|
+
Astro.props.variants.forEach((variant) => {
|
|
39
|
+
if (variant && typeof variant === 'object' && variant.sku) {
|
|
40
|
+
// Build variant name with attributes in logical order: Name + Weight + Flavor + Size + Color + Material
|
|
41
|
+
let variantName = Astro.props.name || "";
|
|
42
|
+
const attributes = [];
|
|
43
|
+
|
|
44
|
+
// Check for amount/quantity first
|
|
45
|
+
if (variant.amount) attributes.push(variant.amount);
|
|
46
|
+
|
|
47
|
+
// Check for weight in different possible property names
|
|
48
|
+
if (variant.weight) attributes.push(variant.weight);
|
|
49
|
+
else if (variant.size && variant.size.includes('kg')) attributes.push(variant.size);
|
|
50
|
+
else if (variant.size && variant.size.includes('g')) attributes.push(variant.size);
|
|
51
|
+
|
|
52
|
+
// Check for flavor in different possible property names
|
|
53
|
+
if (variant.flavor) attributes.push(variant.flavor);
|
|
54
|
+
else if (variant.flavour) attributes.push(variant.flavour);
|
|
55
|
+
else if (variant.taste) attributes.push(variant.taste);
|
|
56
|
+
else if (variant.name && variant.name !== Astro.props.name) {
|
|
57
|
+
// If variant has its own name that's different from product name, use it as flavor
|
|
58
|
+
const variantSpecificName = variant.name.replace(Astro.props.name, '').trim();
|
|
59
|
+
if (variantSpecificName) attributes.push(variantSpecificName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Add other attributes
|
|
63
|
+
if (variant.size && !variant.size.includes('kg') && !variant.size.includes('g')) attributes.push(variant.size);
|
|
64
|
+
if (variant.color) attributes.push(variant.color);
|
|
65
|
+
if (variant.material) attributes.push(variant.material);
|
|
66
|
+
|
|
67
|
+
if (attributes.length > 0 && Astro.props.name) {
|
|
68
|
+
variantName = `${Astro.props.name} ${attributes.join(' ')}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const variantObj = {
|
|
72
|
+
"@type": "Product",
|
|
73
|
+
"sku": variant.sku.toString()
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (variantName) variantObj.name = variantName;
|
|
77
|
+
if (Astro.props.description) variantObj.description = Astro.props.description;
|
|
78
|
+
if ((variant.image || Astro.props.image) && import.meta.env.IMAGE_PROXY_URL) {
|
|
79
|
+
variantObj.image = `${import.meta.env.IMAGE_PROXY_URL}?url=${variant.image || Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`;
|
|
80
|
+
}
|
|
81
|
+
// Use correct schema.org properties
|
|
82
|
+
if (variant.color) variantObj.color = variant.color;
|
|
83
|
+
if (variant.size) variantObj.size = variant.size;
|
|
84
|
+
if (variant.material) variantObj.material = variant.material;
|
|
85
|
+
if (variant.weight) variantObj.weight = variant.weight;
|
|
86
|
+
|
|
87
|
+
// Handle amount and flavor as additionalProperty since they're not standard schema.org properties
|
|
88
|
+
const additionalProperties = [];
|
|
89
|
+
|
|
90
|
+
if (variant.amount) {
|
|
91
|
+
additionalProperties.push({
|
|
92
|
+
"@type": "PropertyValue",
|
|
93
|
+
"name": "amount",
|
|
94
|
+
"value": variant.amount
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Handle flavor with multiple possible property names
|
|
99
|
+
let flavorValue = null;
|
|
100
|
+
if (variant.flavor) flavorValue = variant.flavor;
|
|
101
|
+
else if (variant.flavour) flavorValue = variant.flavour;
|
|
102
|
+
else if (variant.taste) flavorValue = variant.taste;
|
|
103
|
+
|
|
104
|
+
if (flavorValue) {
|
|
105
|
+
additionalProperties.push({
|
|
106
|
+
"@type": "PropertyValue",
|
|
107
|
+
"name": "flavor",
|
|
108
|
+
"value": flavorValue
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (additionalProperties.length > 0) {
|
|
113
|
+
variantObj.additionalProperty = additionalProperties;
|
|
114
|
+
}
|
|
115
|
+
if (variant.gtin13) variantObj.gtin13 = variant.gtin13.toString();
|
|
116
|
+
|
|
117
|
+
const offerObj = {
|
|
118
|
+
"@type": "Offer",
|
|
119
|
+
"url": `${url}${Astro.props.url || ""}?variation=${variant.sku}`,
|
|
120
|
+
"itemCondition": "https://schema.org/NewCondition",
|
|
121
|
+
"availability": variant.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock"
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (variant?.price?.price?.amount) offerObj.price = variant.price.price.amount;
|
|
125
|
+
if (Astro.props.currency) offerObj.priceCurrency = Astro.props.currency;
|
|
126
|
+
|
|
127
|
+
variantObj.offers = offerObj;
|
|
128
|
+
variantsArray.push(variantObj);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
// Single variant - create offers array as before
|
|
134
|
+
if (Array.isArray(Astro.props.variants)) {
|
|
135
|
+
Astro.props.variants.forEach((variant) => {
|
|
136
|
+
if (variant && typeof variant === 'object' && variant.sku) {
|
|
137
|
+
const offerObj = {
|
|
138
|
+
"@type": "Offer",
|
|
139
|
+
"sku": variant.sku.toString(),
|
|
140
|
+
"url": `${url}${Astro.props.url || ""}`,
|
|
141
|
+
"itemCondition": "https://schema.org/NewCondition",
|
|
142
|
+
"availability": variant.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock"
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (variant?.price?.price?.amount) offerObj.price = variant.price.price.amount;
|
|
146
|
+
if (Astro.props.currency) offerObj.priceCurrency = Astro.props.currency;
|
|
147
|
+
|
|
148
|
+
offersArray.push(offerObj);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
20
153
|
|
|
21
154
|
let reviewsArray = []
|
|
22
|
-
Astro.props.reviews
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
155
|
+
if (Astro.props.reviews && Array.isArray(Astro.props.reviews)) {
|
|
156
|
+
Astro.props.reviews.forEach((review)=>{
|
|
157
|
+
if (review && typeof review === 'object') {
|
|
158
|
+
const reviewObj = {
|
|
159
|
+
"@type": "Review",
|
|
160
|
+
"itemReviewed": {
|
|
161
|
+
"@id": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
162
|
+
"name": Astro.props.name || ""
|
|
163
|
+
},
|
|
164
|
+
"reviewRating": {
|
|
165
|
+
"@type": "Rating",
|
|
166
|
+
"bestRating": "5"
|
|
167
|
+
},
|
|
168
|
+
"author": {
|
|
169
|
+
"@type": "Person"
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (review?.elements?.[0]?.score) reviewObj.reviewRating.ratingValue = review.elements[0].score;
|
|
174
|
+
if (review?.authorName) reviewObj.author.name = stripHtml(review.authorName);
|
|
175
|
+
if (review?.posted) reviewObj.datePublished = review.posted;
|
|
176
|
+
if (review?.elements?.[1]?.value) reviewObj.reviewBody = stripHtml(review.elements[1].value);
|
|
177
|
+
|
|
178
|
+
reviewsArray.push(reviewObj);
|
|
179
|
+
}
|
|
40
180
|
});
|
|
41
|
-
}
|
|
181
|
+
}
|
|
42
182
|
|
|
43
|
-
let aggregateRating =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
183
|
+
let aggregateRating = undefined;
|
|
184
|
+
if (Astro.props.reviewsCount > 0) {
|
|
185
|
+
aggregateRating = {
|
|
186
|
+
"@type": "AggregateRating",
|
|
187
|
+
"bestRating": 5,
|
|
188
|
+
"worstRating": 1
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (Astro.props.reviewsAverage) aggregateRating.ratingValue = Astro.props.reviewsAverage;
|
|
192
|
+
if (Astro.props.reviewsCount) aggregateRating.reviewCount = Astro.props.reviewsCount;
|
|
193
|
+
}
|
|
50
194
|
|
|
51
|
-
let schema
|
|
52
|
-
|
|
195
|
+
let schema;
|
|
196
|
+
|
|
197
|
+
if (hasMultipleVariants) {
|
|
198
|
+
// Determine what properties vary between variants
|
|
199
|
+
const variesBy = [];
|
|
200
|
+
|
|
201
|
+
// Check what attributes exist across variants - ensure variants is an array
|
|
202
|
+
if (Array.isArray(Astro.props.variants)) {
|
|
203
|
+
if (Astro.props.variants.some(v => v && v.weight)) variesBy.push("https://schema.org/weight");
|
|
204
|
+
if (Astro.props.variants.some(v => v && v.size)) variesBy.push("https://schema.org/size");
|
|
205
|
+
if (Astro.props.variants.some(v => v && v.color)) variesBy.push("https://schema.org/color");
|
|
206
|
+
if (Astro.props.variants.some(v => v && v.material)) variesBy.push("https://schema.org/material");
|
|
207
|
+
|
|
208
|
+
// For non-standard properties like amount and flavor, we use additionalProperty
|
|
209
|
+
// but still include them in variesBy for semantic meaning
|
|
210
|
+
if (Astro.props.variants.some(v => v && v.amount)) variesBy.push("https://schema.org/additionalProperty");
|
|
211
|
+
|
|
212
|
+
// Check for flavor with multiple possible property names
|
|
213
|
+
if (Astro.props.variants.some(v => v && (v.flavor || v.flavour || v.taste))) {
|
|
214
|
+
// Only add additionalProperty once even if we have multiple non-standard properties
|
|
215
|
+
if (!variesBy.includes("https://schema.org/additionalProperty")) {
|
|
216
|
+
variesBy.push("https://schema.org/additionalProperty");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
schema = {
|
|
222
|
+
"@type": "ProductGroup",
|
|
53
223
|
"@context": "https://schema.org",
|
|
54
|
-
"@id": Astro.props.sku.toString(),
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
224
|
+
"@id": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
225
|
+
"url": `${url}${Astro.props.url || ""}`,
|
|
226
|
+
"productGroupID": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
227
|
+
"hasVariant": variantsArray
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (Astro.props.name) schema.name = Astro.props.name;
|
|
231
|
+
if (Astro.props.description) schema.description = Astro.props.description;
|
|
232
|
+
if (Astro.props.image && import.meta.env.IMAGE_PROXY_URL) {
|
|
233
|
+
schema.image = `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`;
|
|
234
|
+
}
|
|
235
|
+
if (Astro.props.brand) {
|
|
236
|
+
schema.brand = {
|
|
60
237
|
"@type": "Brand",
|
|
61
|
-
"name": Astro.props.brand
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
238
|
+
"name": Astro.props.brand
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (variesBy.length > 0) schema.variesBy = variesBy;
|
|
242
|
+
|
|
243
|
+
// Handle custom properties as additionalProperty (schema.org compliant)
|
|
244
|
+
const additionalProperties = [];
|
|
245
|
+
if (suggestedGender) {
|
|
246
|
+
additionalProperties.push({
|
|
247
|
+
"@type": "PropertyValue",
|
|
248
|
+
"name": "suggestedGender",
|
|
249
|
+
"value": suggestedGender
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (suggestedAge) {
|
|
253
|
+
additionalProperties.push({
|
|
254
|
+
"@type": "PropertyValue",
|
|
255
|
+
"name": "suggestedAge",
|
|
256
|
+
"value": suggestedAge
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (Astro.props.beauty_targetAge) {
|
|
260
|
+
additionalProperties.push({
|
|
261
|
+
"@type": "PropertyValue",
|
|
262
|
+
"name": "targetAge",
|
|
263
|
+
"value": Astro.props.beauty_targetAge
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (Astro.props.gender) {
|
|
267
|
+
additionalProperties.push({
|
|
268
|
+
"@type": "PropertyValue",
|
|
269
|
+
"name": "GenderType",
|
|
270
|
+
"value": Astro.props.gender
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (additionalProperties.length > 0) {
|
|
274
|
+
schema.additionalProperty = additionalProperties;
|
|
275
|
+
}
|
|
276
|
+
if (aggregateRating) schema.aggregateRating = aggregateRating;
|
|
277
|
+
if (reviewsArray.length > 0) schema.review = reviewsArray;
|
|
278
|
+
} else {
|
|
279
|
+
// Single product schema as before
|
|
280
|
+
schema = {
|
|
281
|
+
"@type": "Product",
|
|
282
|
+
"@context": "https://schema.org",
|
|
283
|
+
"@id": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
284
|
+
"sku": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
65
285
|
"offers": offersArray
|
|
66
|
-
};
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
if (Astro.props.name) schema.name = Astro.props.name;
|
|
289
|
+
if (Astro.props.description) schema.description = Astro.props.description;
|
|
290
|
+
if (Astro.props.image && import.meta.env.IMAGE_PROXY_URL) {
|
|
291
|
+
schema.image = `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`;
|
|
292
|
+
}
|
|
293
|
+
if (Astro.props.brand) {
|
|
294
|
+
schema.brand = {
|
|
295
|
+
"@type": "Brand",
|
|
296
|
+
"name": Astro.props.brand
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Handle custom properties as additionalProperty (schema.org compliant)
|
|
301
|
+
const additionalProperties = [];
|
|
302
|
+
if (suggestedGender) {
|
|
303
|
+
additionalProperties.push({
|
|
304
|
+
"@type": "PropertyValue",
|
|
305
|
+
"name": "suggestedGender",
|
|
306
|
+
"value": suggestedGender
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (suggestedAge) {
|
|
310
|
+
additionalProperties.push({
|
|
311
|
+
"@type": "PropertyValue",
|
|
312
|
+
"name": "suggestedAge",
|
|
313
|
+
"value": suggestedAge
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (Astro.props.beauty_targetAge) {
|
|
317
|
+
additionalProperties.push({
|
|
318
|
+
"@type": "PropertyValue",
|
|
319
|
+
"name": "beauty_targetAge",
|
|
320
|
+
"value": Astro.props.beauty_targetAge
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (Astro.props.gender) {
|
|
324
|
+
additionalProperties.push({
|
|
325
|
+
"@type": "PropertyValue",
|
|
326
|
+
"name": "gender",
|
|
327
|
+
"value": Astro.props.gender
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (additionalProperties.length > 0) {
|
|
331
|
+
schema.additionalProperty = additionalProperties;
|
|
332
|
+
}
|
|
333
|
+
if (aggregateRating) schema.aggregateRating = aggregateRating;
|
|
334
|
+
if (reviewsArray.length > 0) schema.review = reviewsArray;
|
|
335
|
+
}
|
|
67
336
|
|
|
68
337
|
---
|
|
69
338
|
|
|
70
|
-
<script type="application/ld+json" set:html={JSON.stringify(schema)
|
|
339
|
+
<script type="application/ld+json" set:html={JSON.stringify(schema, (key, value) => {
|
|
340
|
+
// Filter out undefined, null, and empty string values
|
|
341
|
+
if (value === undefined || value === null || value === '') {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
})}></script>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
---
|
|
2
|
+
const { contents, author, image, name, title } = Astro.props
|
|
3
|
+
|
|
4
|
+
const recipeDetails = contents?.find(
|
|
5
|
+
(obj) => obj.type === 'recipeDetails'
|
|
6
|
+
)?.props
|
|
7
|
+
const hasRecipe =
|
|
8
|
+
contents?.find((obj) => obj.type?.includes('recipe')) !== undefined
|
|
9
|
+
|
|
10
|
+
const recipeIngredientsData = contents?.find((obj) => obj.type === 'recipeIngredients')?.props
|
|
11
|
+
const recipeInstructionsData = contents?.find((obj) => obj.type === 'recipeInstructions')?.props
|
|
12
|
+
const recipeNutritionData = contents?.find((obj) => obj.type === 'recipeNutrition')?.props
|
|
13
|
+
|
|
14
|
+
// Build recipe schema data
|
|
15
|
+
const recipeSchemaData = {}
|
|
16
|
+
|
|
17
|
+
// Add basic recipe information - try multiple possible property names
|
|
18
|
+
// Check props first, then recipeDetails
|
|
19
|
+
if (name || title || recipeDetails?.title || recipeDetails?.name) {
|
|
20
|
+
recipeSchemaData.name = name || title || recipeDetails.title || recipeDetails.name
|
|
21
|
+
}
|
|
22
|
+
if (recipeDetails?.description || recipeDetails?.summary) {
|
|
23
|
+
recipeSchemaData.description = recipeDetails.description || recipeDetails.summary
|
|
24
|
+
}
|
|
25
|
+
if (recipeDetails?.cookTime || recipeDetails?.cookingTime || recipeDetails?.cook_time) {
|
|
26
|
+
recipeSchemaData.cookTime = recipeDetails.cookTime || recipeDetails.cookingTime || recipeDetails.cook_time
|
|
27
|
+
}
|
|
28
|
+
if (recipeDetails?.prepTime || recipeDetails?.preparationTime || recipeDetails?.prep_time) {
|
|
29
|
+
recipeSchemaData.prepTime = recipeDetails.prepTime || recipeDetails.preparationTime || recipeDetails.prep_time
|
|
30
|
+
}
|
|
31
|
+
if (recipeDetails?.servings || recipeDetails?.serves || recipeDetails?.yield) {
|
|
32
|
+
recipeSchemaData.recipeYield = recipeDetails.servings || recipeDetails.serves || recipeDetails.yield
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Add ingredients - convert to simple string array
|
|
36
|
+
if (recipeIngredientsData) {
|
|
37
|
+
let ingredients = null
|
|
38
|
+
|
|
39
|
+
// Try different property names
|
|
40
|
+
ingredients = recipeIngredientsData.ingredients ||
|
|
41
|
+
recipeIngredientsData.items ||
|
|
42
|
+
recipeIngredientsData.list ||
|
|
43
|
+
recipeIngredientsData.ingredientList
|
|
44
|
+
|
|
45
|
+
// If it's an array directly
|
|
46
|
+
if (!ingredients && Array.isArray(recipeIngredientsData)) {
|
|
47
|
+
ingredients = recipeIngredientsData
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// If it's an object with nested data
|
|
51
|
+
if (!ingredients && typeof recipeIngredientsData === 'object') {
|
|
52
|
+
// Try to find any array property
|
|
53
|
+
for (const key in recipeIngredientsData) {
|
|
54
|
+
if (Array.isArray(recipeIngredientsData[key])) {
|
|
55
|
+
ingredients = recipeIngredientsData[key]
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (ingredients && Array.isArray(ingredients) && ingredients.length > 0) {
|
|
62
|
+
// Convert ingredient objects to strings
|
|
63
|
+
recipeSchemaData.recipeIngredient = ingredients.map(ingredient => {
|
|
64
|
+
if (typeof ingredient === 'string') {
|
|
65
|
+
return ingredient
|
|
66
|
+
} else if (ingredient?.ingredient) {
|
|
67
|
+
// Combine quantity, unit, and ingredient into a string
|
|
68
|
+
const parts = []
|
|
69
|
+
if (ingredient.quantity) parts.push(ingredient.quantity)
|
|
70
|
+
if (ingredient.unit) parts.push(ingredient.unit)
|
|
71
|
+
if (ingredient.ingredient) parts.push(ingredient.ingredient)
|
|
72
|
+
return parts.join(' ')
|
|
73
|
+
} else if (ingredient?.name) {
|
|
74
|
+
return ingredient.name
|
|
75
|
+
}
|
|
76
|
+
return String(ingredient)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add instructions - convert to simple string array
|
|
82
|
+
if (recipeInstructionsData) {
|
|
83
|
+
let instructions = null
|
|
84
|
+
|
|
85
|
+
// Try different property names
|
|
86
|
+
instructions = recipeInstructionsData.instructions ||
|
|
87
|
+
recipeInstructionsData.steps ||
|
|
88
|
+
recipeInstructionsData.directions ||
|
|
89
|
+
recipeInstructionsData.method ||
|
|
90
|
+
recipeInstructionsData.stepList
|
|
91
|
+
|
|
92
|
+
// If it's an array directly
|
|
93
|
+
if (!instructions && Array.isArray(recipeInstructionsData)) {
|
|
94
|
+
instructions = recipeInstructionsData
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// If it's an object with nested data
|
|
98
|
+
if (!instructions && typeof recipeInstructionsData === 'object') {
|
|
99
|
+
// Try to find any array property
|
|
100
|
+
for (const key in recipeInstructionsData) {
|
|
101
|
+
if (Array.isArray(recipeInstructionsData[key])) {
|
|
102
|
+
instructions = recipeInstructionsData[key]
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (instructions && Array.isArray(instructions) && instructions.length > 0) {
|
|
109
|
+
// Convert instruction objects to strings
|
|
110
|
+
recipeSchemaData.recipeInstructions = instructions.map(instruction => {
|
|
111
|
+
if (typeof instruction === 'string') {
|
|
112
|
+
return instruction
|
|
113
|
+
} else if (instruction?.instruction) {
|
|
114
|
+
// Strip HTML tags from instruction text
|
|
115
|
+
return instruction.instruction.replace(/<[^>]*>/g, '')
|
|
116
|
+
} else if (instruction?.text) {
|
|
117
|
+
return instruction.text.replace(/<[^>]*>/g, '')
|
|
118
|
+
}
|
|
119
|
+
return String(instruction).replace(/<[^>]*>/g, '')
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Add nutrition information - map to correct Schema.org property names
|
|
125
|
+
if (recipeNutritionData?.nutritionalInformation || recipeNutritionData) {
|
|
126
|
+
const nutritionData = recipeNutritionData?.nutritionalInformation || recipeNutritionData
|
|
127
|
+
|
|
128
|
+
const nutrition = {
|
|
129
|
+
'@type': 'NutritionInformation'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Map nutrition properties to Schema.org names
|
|
133
|
+
if (nutritionData.calories) nutrition.calories = nutritionData.calories
|
|
134
|
+
if (nutritionData.totalFat) nutrition.fatContent = nutritionData.totalFat
|
|
135
|
+
if (nutritionData.saturatedFat) nutrition.saturatedFatContent = nutritionData.saturatedFat
|
|
136
|
+
if (nutritionData.transFat) nutrition.transFatContent = nutritionData.transFat
|
|
137
|
+
if (nutritionData.cholesterol) nutrition.cholesterolContent = nutritionData.cholesterol
|
|
138
|
+
if (nutritionData.sodium) nutrition.sodiumContent = nutritionData.sodium
|
|
139
|
+
if (nutritionData.totalCarbohydrates) nutrition.carbohydrateContent = nutritionData.totalCarbohydrates
|
|
140
|
+
if (nutritionData.dietaryFiber) nutrition.fiberContent = nutritionData.dietaryFiber
|
|
141
|
+
if (nutritionData.sugar) nutrition.sugarContent = nutritionData.sugar
|
|
142
|
+
if (nutritionData.protein) nutrition.proteinContent = nutritionData.protein
|
|
143
|
+
|
|
144
|
+
recipeSchemaData.nutrition = nutrition
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add image if available
|
|
148
|
+
if (image?.mediaItemUrl) {
|
|
149
|
+
recipeSchemaData.image = image.mediaItemUrl
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Add author if available
|
|
153
|
+
if (author?.name) {
|
|
154
|
+
recipeSchemaData.author = {
|
|
155
|
+
'@type': 'Person',
|
|
156
|
+
name: author.name
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const recipeSchema = {
|
|
161
|
+
'@context': 'https://schema.org',
|
|
162
|
+
'@type': 'Recipe',
|
|
163
|
+
...recipeSchemaData
|
|
164
|
+
}
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
hasRecipe && (
|
|
169
|
+
<script
|
|
170
|
+
is:inline
|
|
171
|
+
type='application/ld+json'
|
|
172
|
+
set:html={JSON.stringify(recipeSchema)}
|
|
173
|
+
/>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps product data to schema-friendly format for nutrition products
|
|
3
|
+
* Extracts gender and age information from Mars API data structure
|
|
4
|
+
*/
|
|
5
|
+
export function mapProductSchemaData(productData) {
|
|
6
|
+
let suggestedGender = null;
|
|
7
|
+
let suggestedAge = null;
|
|
8
|
+
|
|
9
|
+
// First, try to extract from Mars API structure (productContent.productContentStrictLanguage.propertyNameToValueMap)
|
|
10
|
+
const marsData = productData?.productContent?.productContentStrictLanguage?.propertyNameToValueMap;
|
|
11
|
+
if (marsData) {
|
|
12
|
+
// Extract gender from Mars data
|
|
13
|
+
if (marsData.gender && Array.isArray(marsData.gender) && marsData.gender.length > 0) {
|
|
14
|
+
suggestedGender = marsData.gender[0]; // Take first value: "Men"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Extract age from Mars data
|
|
18
|
+
if (marsData.googleAgeGroup && Array.isArray(marsData.googleAgeGroup) && marsData.googleAgeGroup.length > 0) {
|
|
19
|
+
suggestedAge = marsData.googleAgeGroup[0]; // Take first value: "adult"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Fallback: try to extract from content array (your current structure)
|
|
24
|
+
if (!suggestedGender || !suggestedAge) {
|
|
25
|
+
if (productData?.content && Array.isArray(productData.content)) {
|
|
26
|
+
// Helper function to find content item by key
|
|
27
|
+
const findContentItem = (key) => {
|
|
28
|
+
return productData.content.find(item => item.key === key);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Helper function to extract value from content item
|
|
32
|
+
const extractValue = (contentItem) => {
|
|
33
|
+
if (!contentItem?.value) return null;
|
|
34
|
+
|
|
35
|
+
if (contentItem.value.stringValue) {
|
|
36
|
+
return contentItem.value.stringValue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (contentItem.value.stringListValue && Array.isArray(contentItem.value.stringListValue)) {
|
|
40
|
+
if (contentItem.value.stringListValue.length > 0 && contentItem.value.stringListValue[0]) {
|
|
41
|
+
return contentItem.value.stringListValue[0];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Extract gender from content array if not found in Mars data
|
|
49
|
+
if (!suggestedGender) {
|
|
50
|
+
const genderItem = findContentItem('gender') || findContentItem('Gender') || findContentItem('targetGender');
|
|
51
|
+
if (genderItem) {
|
|
52
|
+
suggestedGender = extractValue(genderItem);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Extract age from content array if not found in Mars data
|
|
57
|
+
if (!suggestedAge) {
|
|
58
|
+
const ageItem = findContentItem('googleAgeGroup') || findContentItem('ageGroup') || findContentItem('targetAge') || findContentItem('age');
|
|
59
|
+
if (ageItem) {
|
|
60
|
+
suggestedAge = extractValue(ageItem);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If no gender/age found anywhere, provide reasonable defaults for nutrition products
|
|
67
|
+
if (!suggestedGender && !suggestedAge) {
|
|
68
|
+
return {
|
|
69
|
+
suggestedGender: "Unisex",
|
|
70
|
+
suggestedAge: "Adult"
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Return the extracted values
|
|
75
|
+
const result = {};
|
|
76
|
+
if (suggestedGender) result.suggestedGender = suggestedGender;
|
|
77
|
+
if (suggestedAge) result.suggestedAge = suggestedAge;
|
|
78
|
+
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Enhanced version with additional nutrition-specific properties
|
|
84
|
+
*/
|
|
85
|
+
export function mapProductSchemaDataEnhanced(productData) {
|
|
86
|
+
const basicMapping = mapProductSchemaData(productData);
|
|
87
|
+
|
|
88
|
+
if (!productData?.content || !Array.isArray(productData.content)) {
|
|
89
|
+
return basicMapping;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Helper function to find and extract content values
|
|
93
|
+
const findAndExtract = (key) => {
|
|
94
|
+
const item = productData.content.find(item => item.key === key);
|
|
95
|
+
if (!item?.value) return null;
|
|
96
|
+
|
|
97
|
+
if (item.value.stringValue) return item.value.stringValue;
|
|
98
|
+
if (item.value.stringListValue && Array.isArray(item.value.stringListValue)) {
|
|
99
|
+
return item.value.stringListValue[0];
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...basicMapping,
|
|
106
|
+
brand: findAndExtract('brand'),
|
|
107
|
+
diet: findAndExtract('Diet'),
|
|
108
|
+
range: findAndExtract('range'),
|
|
109
|
+
// Add any other fields you need from the content array
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Simple function to strip HTML tags from text
|
|
115
|
+
*/
|
|
116
|
+
export function stripHtml(html) {
|
|
117
|
+
if (!html) return '';
|
|
118
|
+
return html.replace(/<[^>]*>/g, '');
|
|
119
|
+
}
|