@thg-altitude/schemaorg 1.0.32 → 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 CHANGED
@@ -1 +1 @@
1
- 18.15.0
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@thg-altitude/schemaorg",
3
- "version": "1.0.32",
3
+ "version": "1.0.33",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
- offersArray = variants
68
- .filter((variant) => variant?.sku && variant?.price?.price?.amount)
69
- .map((variant) => ({
70
- "@type": "Offer",
71
- sku: variant.sku.toString(),
72
- url:
73
- variants.length > 1
74
- ? `${pageUrl}?variation=${variant?.sku}`
75
- : pageUrl,
76
- price: variant.price.price.amount,
77
- priceCurrency: currency,
78
- itemCondition: "http://schema.org/NewCondition",
79
- availability: variant?.inStock
80
- ? "https://schema.org/InStock"
81
- : "https://schema.org/OutOfStock",
82
- ...(variant?.barcode && { gtin13: variant.barcode.toString() }),
83
- ...(brand && {
84
- seller: {
85
- "@type": "Organization",
86
- name: brand,
87
- },
88
- }),
89
- priceSpecification: [
90
- {
91
- "@type": "UnitPriceSpecification",
92
- priceCurrency: currency,
93
- price: variant.price.price.amount,
94
- valueAddedTaxIncluded: true,
95
- },
96
- ...(variant?.price?.rrp?.amount
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.rrp.amount,
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 productSchema = {
120
- "@type": "Product",
121
- "@id": pageUrl,
122
- url: pageUrl,
123
- sku: sku.toString(),
124
- name: name,
125
- ...(gtin13 && { gtin13: gtin13.toString() }),
126
- ...(description && { description }),
127
- ...(imageUrl && { image: imageUrl }),
128
- ...(brand && {
129
- brand: {
130
- "@type": "Brand",
131
- name: brand,
132
- },
133
- }),
134
- ...(reviewsCount > 0 &&
135
- reviewsAverage && {
136
- aggregateRating: {
137
- "@type": "AggregateRating",
138
- ratingValue: reviewsAverage,
139
- reviewCount: reviewsCount,
140
- bestRating: 5,
141
- worstRating: 1,
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
- ...(reviewsArray && reviewsArray.length > 0 && { review: reviewsArray }),
145
- ...(category && { category }),
146
- ...(keywords && { keywords }),
147
- ...(offersArray.length > 0 && { offers: offersArray }),
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
- graphArray.push(productSchema);
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={JSON.stringify(schema)} />
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 '@thg-altitude/utils'
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
- Astro.props.variants?.forEach((variant)=>{
10
- offersArray.push({
11
- "@type": "Offer",
12
- "sku": variant.sku.toString(),
13
- "url": Astro.props.variants.length > 1 ? `${url}${Astro.props.url}?variation=${variant?.sku}`: `${url}${Astro.props.url}`,
14
- "price": variant?.price?.price?.amount,
15
- "priceCurrency": Astro.props.currency,
16
- "itemCondition": "http://schema.org/NewCondition",
17
- "availability": variant?.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock"
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?.forEach((review)=>{
23
- reviewsArray.push({
24
- "@type": "Review",
25
- itemReviewed: {
26
- "@id": Astro.props.sku.toString(),
27
- name: Astro.props.name,
28
- },
29
- reviewRating: {
30
- "@type": "Rating",
31
- ratingValue: review?.elements?.[0]?.score,
32
- bestRating: "5",
33
- },
34
- author: {
35
- "@type": "Person",
36
- name: stripHtml(review?.authorName),
37
- },
38
- datePublished: review?.posted,
39
- reviewBody: stripHtml(review?.elements?.[1]?.value),
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 = Astro.props.reviewsCount > 0 ? {
44
- "@type": "AggregateRating",
45
- "ratingValue": Astro.props.reviewsAverage,
46
- "reviewCount": Astro.props.reviewsCount,
47
- bestRating: 5,
48
- worstRating: 1,
49
- } : undefined
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
- "@type": "Product",
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
- "sku": Astro.props.sku.toString(),
56
- "name": Astro.props.name,
57
- "description": Astro.props.description,
58
- "image": `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`,
59
- "brand": {
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
- "aggregateRating": aggregateRating,
64
- "review": reviewsArray.length ? reviewsArray : null,
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)}></script>
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
+ }