@tokenoftrust/storefront-runner 2.4.72 → 2.4.74
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/apps/storefront/src/components/blog/BlogIndexView.astro +6 -2
- package/apps/storefront/src/components/blog/BlogPostArticle.astro +146 -0
- package/apps/storefront/src/lib/blog/collectionPaths.ts +23 -2
- package/apps/storefront/src/lib/blog/postView.ts +141 -0
- package/apps/storefront/src/lib/blog/types.ts +10 -0
- package/apps/storefront/src/lib/chrome/model.ts +5 -0
- package/apps/storefront/src/lib/publication/rootDiscovery.ts +5 -4
- package/apps/storefront/src/pages/[...slug].astro +107 -78
- package/apps/storefront/src/pages/blog/[slug].astro +8 -218
- package/apps/storefront/src/pages/blog/author/[author].astro +4 -3
- package/apps/storefront/src/pages/blog/category/[category].astro +4 -3
- package/apps/storefront/src/pages/blog/index.astro +1 -0
- package/apps/storefront/src/pages/blog/page/[n].astro +1 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +4 -3
- package/apps/storefront/src/pages/llms.txt.ts +5 -4
- package/apps/storefront/src/pages/products/[handle].astro +85 -18
- package/apps/storefront/src/pages/sitemap.xml.ts +3 -3
- package/package.json +1 -1
- package/packages/public-runtime/src/chrome.ts +158 -0
- package/packages/public-runtime/src/index.ts +1 -0
- package/packages/public-runtime/src/product-details.ts +160 -0
|
@@ -407,6 +407,89 @@ export interface ChromeConfig {
|
|
|
407
407
|
* compliance.product_category) — the source store renders a different legal
|
|
408
408
|
* block per type (sub-ohm on salts, battery on devices). */
|
|
409
409
|
disclaimers?: Record<string, string>;
|
|
410
|
+
/**
|
|
411
|
+
* Declared PDP metafield->section mapping (see {@link ProductDetailsConfig}).
|
|
412
|
+
* Replaces any hardcoded metafield-namespace convention in the renderer: a
|
|
413
|
+
* tenant whose catalog carries spec/FAQ/ingredient data under its own
|
|
414
|
+
* namespace declares the mapping here, and the PDP renders it — never a
|
|
415
|
+
* namespace the template assumes by tenant id.
|
|
416
|
+
*/
|
|
417
|
+
productDetails?: ProductDetailsConfig;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** One "Product Details" spec-table row, sourced from one declared metafield. */
|
|
421
|
+
export interface ProductDetailSpecRow {
|
|
422
|
+
/** Metafield namespace, e.g. "custom". */
|
|
423
|
+
namespace: string;
|
|
424
|
+
/** Metafield key, e.g. "dv_puff_count". */
|
|
425
|
+
key: string;
|
|
426
|
+
/** Row label, e.g. "Puff Count". */
|
|
427
|
+
label: string;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** One declared "Label: value" prose line below the description. */
|
|
431
|
+
export interface ProductAttributeLine {
|
|
432
|
+
namespace: string;
|
|
433
|
+
key: string;
|
|
434
|
+
label: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** A metafield reference (namespace + key), no label — used where the section
|
|
438
|
+
* itself supplies the heading/copy. */
|
|
439
|
+
export interface ProductMetafieldRef {
|
|
440
|
+
namespace: string;
|
|
441
|
+
key: string;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* FAQ entries stored as a fixed-width set of numbered metafields
|
|
446
|
+
* (`faq_q1`/`faq_a1` .. `faq_qN`/`faq_aN`) — the convention a source store's
|
|
447
|
+
* migrated FAQ block commonly uses. `{n}` in each pattern is replaced with
|
|
448
|
+
* 1..maxItems; a product missing a given pair simply has no entry for that n.
|
|
449
|
+
*/
|
|
450
|
+
export interface ProductDetailFaqConfig {
|
|
451
|
+
namespace: string;
|
|
452
|
+
/** Question key template containing a literal `{n}`, e.g. "faq_q{n}". */
|
|
453
|
+
questionKeyPattern: string;
|
|
454
|
+
/** Answer key template containing a literal `{n}`, e.g. "faq_a{n}". */
|
|
455
|
+
answerKeyPattern: string;
|
|
456
|
+
/** Highest n to probe (1-based). */
|
|
457
|
+
maxItems: number;
|
|
458
|
+
/** Section heading. Defaults to "Frequently Asked Questions". */
|
|
459
|
+
heading?: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Declared mapping from a tenant's own catalog metafield namespace/keys to PDP
|
|
464
|
+
* sections. Every section is OMITTED when its declared metafield has no value
|
|
465
|
+
* on a given product — never rendered with an empty label. Undeclared
|
|
466
|
+
* metafields are never read: only what's listed here reaches the page.
|
|
467
|
+
*/
|
|
468
|
+
export interface ProductDetailsConfig {
|
|
469
|
+
/** Ordered fallback list for the short summary shown near the buy button
|
|
470
|
+
* (first metafield with a value wins). */
|
|
471
|
+
shortDescription?: ProductMetafieldRef[];
|
|
472
|
+
/** Ordered "Label: value" prose lines below the long description. */
|
|
473
|
+
attributeLines?: ProductAttributeLine[];
|
|
474
|
+
/** "Product Details" spec-table/grid rows, in declared order. */
|
|
475
|
+
specRows?: ProductDetailSpecRow[];
|
|
476
|
+
/** Heading for the spec-row section. Defaults to "Product Details". */
|
|
477
|
+
specHeading?: string;
|
|
478
|
+
/** The ingredients paragraph metafield. */
|
|
479
|
+
ingredients?: ProductMetafieldRef & { heading?: string };
|
|
480
|
+
/** Byline under the ingredients text, e.g. "Fact Checked By: Staff". Static
|
|
481
|
+
* copy (not a metafield); shown only when `ingredients` resolves to a value. */
|
|
482
|
+
factCheckedBy?: string;
|
|
483
|
+
/**
|
|
484
|
+
* Age/compliance notice (e.g. "Adult Use Only (21+)"), rendered only when
|
|
485
|
+
* its declared metafield has a value. Copy is declared here, never a fixed
|
|
486
|
+
* string in the template, so it's accurate for any tenant/jurisdiction.
|
|
487
|
+
*/
|
|
488
|
+
ageNotice?: ProductMetafieldRef & { title: string; text: string };
|
|
489
|
+
/** FAQ block sourced from numbered metafields. */
|
|
490
|
+
faq?: ProductDetailFaqConfig;
|
|
491
|
+
/** Box/package-contents line. */
|
|
492
|
+
boxContents?: ProductMetafieldRef & { label?: string };
|
|
410
493
|
}
|
|
411
494
|
|
|
412
495
|
// ---------------------------------------------------------------------------
|
|
@@ -726,6 +809,80 @@ function validateFooter(v: unknown, path: string, errors: string[]): v is Chrome
|
|
|
726
809
|
return errors.length === before;
|
|
727
810
|
}
|
|
728
811
|
|
|
812
|
+
function validateMetafieldRef(v: unknown, path: string, errors: string[]): void {
|
|
813
|
+
if (!isObject(v)) {
|
|
814
|
+
errors.push(`${path}: must be an object`);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (!isNonEmptyString(v.namespace)) errors.push(`${path}.namespace: required, non-empty string`);
|
|
818
|
+
if (!isNonEmptyString(v.key)) errors.push(`${path}.key: required, non-empty string`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function validateProductDetails(v: unknown, path: string, errors: string[]): void {
|
|
822
|
+
if (!isObject(v)) {
|
|
823
|
+
errors.push(`${path}: must be an object`);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (v.shortDescription !== undefined) {
|
|
827
|
+
if (!Array.isArray(v.shortDescription)) errors.push(`${path}.shortDescription: must be an array`);
|
|
828
|
+
else v.shortDescription.forEach((r, i) => validateMetafieldRef(r, `${path}.shortDescription[${i}]`, errors));
|
|
829
|
+
}
|
|
830
|
+
if (v.attributeLines !== undefined) {
|
|
831
|
+
if (!Array.isArray(v.attributeLines)) errors.push(`${path}.attributeLines: must be an array`);
|
|
832
|
+
else
|
|
833
|
+
v.attributeLines.forEach((r, i) => {
|
|
834
|
+
validateMetafieldRef(r, `${path}.attributeLines[${i}]`, errors);
|
|
835
|
+
if (isObject(r) && !isNonEmptyString(r.label)) {
|
|
836
|
+
errors.push(`${path}.attributeLines[${i}].label: required, non-empty string`);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
if (v.specRows !== undefined) {
|
|
841
|
+
if (!Array.isArray(v.specRows)) errors.push(`${path}.specRows: must be an array`);
|
|
842
|
+
else
|
|
843
|
+
v.specRows.forEach((r, i) => {
|
|
844
|
+
validateMetafieldRef(r, `${path}.specRows[${i}]`, errors);
|
|
845
|
+
if (isObject(r) && !isNonEmptyString(r.label)) {
|
|
846
|
+
errors.push(`${path}.specRows[${i}].label: required, non-empty string`);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
checkOptionalString(v.specHeading, `${path}.specHeading`, errors);
|
|
851
|
+
if (v.ingredients !== undefined) {
|
|
852
|
+
validateMetafieldRef(v.ingredients, `${path}.ingredients`, errors);
|
|
853
|
+
if (isObject(v.ingredients)) checkOptionalString(v.ingredients.heading, `${path}.ingredients.heading`, errors);
|
|
854
|
+
}
|
|
855
|
+
checkOptionalString(v.factCheckedBy, `${path}.factCheckedBy`, errors);
|
|
856
|
+
if (v.ageNotice !== undefined) {
|
|
857
|
+
validateMetafieldRef(v.ageNotice, `${path}.ageNotice`, errors);
|
|
858
|
+
if (isObject(v.ageNotice)) {
|
|
859
|
+
if (!isNonEmptyString(v.ageNotice.title)) errors.push(`${path}.ageNotice.title: required, non-empty string`);
|
|
860
|
+
if (!isNonEmptyString(v.ageNotice.text)) errors.push(`${path}.ageNotice.text: required, non-empty string`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (v.faq !== undefined) {
|
|
864
|
+
if (!isObject(v.faq)) {
|
|
865
|
+
errors.push(`${path}.faq: must be an object`);
|
|
866
|
+
} else {
|
|
867
|
+
if (!isNonEmptyString(v.faq.namespace)) errors.push(`${path}.faq.namespace: required, non-empty string`);
|
|
868
|
+
if (!isNonEmptyString(v.faq.questionKeyPattern) || !v.faq.questionKeyPattern.includes("{n}")) {
|
|
869
|
+
errors.push(`${path}.faq.questionKeyPattern: required string containing "{n}"`);
|
|
870
|
+
}
|
|
871
|
+
if (!isNonEmptyString(v.faq.answerKeyPattern) || !v.faq.answerKeyPattern.includes("{n}")) {
|
|
872
|
+
errors.push(`${path}.faq.answerKeyPattern: required string containing "{n}"`);
|
|
873
|
+
}
|
|
874
|
+
if (typeof v.faq.maxItems !== "number" || !Number.isInteger(v.faq.maxItems) || v.faq.maxItems < 1) {
|
|
875
|
+
errors.push(`${path}.faq.maxItems: required positive integer`);
|
|
876
|
+
}
|
|
877
|
+
checkOptionalString(v.faq.heading, `${path}.faq.heading`, errors);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (v.boxContents !== undefined) {
|
|
881
|
+
validateMetafieldRef(v.boxContents, `${path}.boxContents`, errors);
|
|
882
|
+
if (isObject(v.boxContents)) checkOptionalString(v.boxContents.label, `${path}.boxContents.label`, errors);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
729
886
|
/**
|
|
730
887
|
* Validate `raw` (parsed JSON) as a `ChromeConfig`. Collects every violation
|
|
731
888
|
* (not just the first) so a caller gets one actionable report. Trusts the
|
|
@@ -754,6 +911,7 @@ export function validateChromeConfig(raw: unknown): ChromeConfigValidationResult
|
|
|
754
911
|
if (!isObject(raw.breadcrumbs)) errors.push("breadcrumbs: must be an object");
|
|
755
912
|
else checkOptionalString(raw.breadcrumbs.homeLabel, "breadcrumbs.homeLabel", errors);
|
|
756
913
|
}
|
|
914
|
+
if (raw.productDetails !== undefined) validateProductDetails(raw.productDetails, "productDetails", errors);
|
|
757
915
|
|
|
758
916
|
if (errors.length > 0) return { ok: false, config: null, errors };
|
|
759
917
|
return { ok: true, config: raw as unknown as ChromeConfig, errors: [] };
|
|
@@ -50,6 +50,7 @@ export * from "./customization-versioning.js";
|
|
|
50
50
|
export * from "./customization-scripts.js";
|
|
51
51
|
export * from "./customization-reconcile.js";
|
|
52
52
|
export * from "./chrome.js";
|
|
53
|
+
export * from "./product-details.js";
|
|
53
54
|
// Declarative interaction tracking: the data-tot-el / -action / -placement
|
|
54
55
|
// attribute contract, its single emit path, and the ONE delegated click
|
|
55
56
|
// listener both document mounts install. Reads declared attributes only —
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a product's declared PDP sections (spec rows, ingredients, FAQ, box
|
|
3
|
+
* contents, age notice, short description, attribute lines) from its raw
|
|
4
|
+
* metafields plus the tenant's declared `ChromeConfig.productDetails` mapping.
|
|
5
|
+
*
|
|
6
|
+
* Pure and dependency-free: no namespace/key convention is read unless the
|
|
7
|
+
* tenant's own `productDetails` config names it, and a section whose declared
|
|
8
|
+
* metafield has no value on a given product is OMITTED entirely — never
|
|
9
|
+
* rendered with an empty label. This is what replaces a renderer hardcoding
|
|
10
|
+
* one merchant's metafield-namespace convention (e.g. "my_fields").
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
ProductDetailsConfig,
|
|
14
|
+
ProductDetailSpecRow,
|
|
15
|
+
ProductAttributeLine,
|
|
16
|
+
} from "./chrome.js";
|
|
17
|
+
import type { Metafield } from "./product.js";
|
|
18
|
+
|
|
19
|
+
export interface ResolvedSpecRow {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResolvedAttributeLine {
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvedFaqItem {
|
|
30
|
+
question: string;
|
|
31
|
+
answer: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolvedProductDetails {
|
|
35
|
+
shortDescription?: string;
|
|
36
|
+
attributeLines: ResolvedAttributeLine[];
|
|
37
|
+
specHeading: string;
|
|
38
|
+
specRows: ResolvedSpecRow[];
|
|
39
|
+
ingredients?: { heading: string; text: string };
|
|
40
|
+
factCheckedBy?: string;
|
|
41
|
+
ageNotice?: { title: string; text: string };
|
|
42
|
+
faq?: { heading: string; items: ResolvedFaqItem[] };
|
|
43
|
+
boxContents?: { label: string; value: string };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_SPEC_HEADING = "Product Details";
|
|
47
|
+
const DEFAULT_FAQ_HEADING = "Frequently Asked Questions";
|
|
48
|
+
const DEFAULT_BOX_CONTENTS_LABEL = "What's in the box";
|
|
49
|
+
|
|
50
|
+
function metafieldLookup(metafields: readonly Metafield[]): (namespace: string, key: string) => string | undefined {
|
|
51
|
+
const map = new Map<string, string>();
|
|
52
|
+
for (const m of metafields) {
|
|
53
|
+
const v = m.value?.trim();
|
|
54
|
+
if (v) map.set(`${m.namespace}.${m.key}`, v);
|
|
55
|
+
}
|
|
56
|
+
return (namespace, key) => map.get(`${namespace}.${key}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a product's declared sections. `config` undefined/empty => every
|
|
61
|
+
* section is absent (no fallback to any hardcoded namespace).
|
|
62
|
+
*/
|
|
63
|
+
export function resolveProductDetails(
|
|
64
|
+
metafields: readonly Metafield[] | undefined,
|
|
65
|
+
config: ProductDetailsConfig | undefined,
|
|
66
|
+
): ResolvedProductDetails {
|
|
67
|
+
const mf = metafieldLookup(metafields ?? []);
|
|
68
|
+
const out: ResolvedProductDetails = {
|
|
69
|
+
attributeLines: [],
|
|
70
|
+
specHeading: config?.specHeading?.trim() || DEFAULT_SPEC_HEADING,
|
|
71
|
+
specRows: [],
|
|
72
|
+
};
|
|
73
|
+
if (!config) return out;
|
|
74
|
+
|
|
75
|
+
if (config.shortDescription) {
|
|
76
|
+
for (const ref of config.shortDescription) {
|
|
77
|
+
const value = mf(ref.namespace, ref.key);
|
|
78
|
+
if (value) {
|
|
79
|
+
out.shortDescription = value;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (config.attributeLines) {
|
|
86
|
+
out.attributeLines = resolveAttributeLines(config.attributeLines, mf);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (config.specRows) {
|
|
90
|
+
out.specRows = resolveSpecRows(config.specRows, mf);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (config.ingredients) {
|
|
94
|
+
const value = mf(config.ingredients.namespace, config.ingredients.key);
|
|
95
|
+
if (value) {
|
|
96
|
+
out.ingredients = { heading: config.ingredients.heading?.trim() || "Ingredients", text: value };
|
|
97
|
+
if (config.factCheckedBy?.trim()) out.factCheckedBy = config.factCheckedBy.trim();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (config.ageNotice) {
|
|
102
|
+
const value = mf(config.ageNotice.namespace, config.ageNotice.key);
|
|
103
|
+
if (value) out.ageNotice = { title: config.ageNotice.title, text: config.ageNotice.text };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (config.faq) {
|
|
107
|
+
const items = resolveFaq(config.faq, mf);
|
|
108
|
+
if (items.length > 0) {
|
|
109
|
+
out.faq = { heading: config.faq.heading?.trim() || DEFAULT_FAQ_HEADING, items };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (config.boxContents) {
|
|
114
|
+
const value = mf(config.boxContents.namespace, config.boxContents.key);
|
|
115
|
+
if (value) {
|
|
116
|
+
out.boxContents = { label: config.boxContents.label?.trim() || DEFAULT_BOX_CONTENTS_LABEL, value };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveAttributeLines(
|
|
124
|
+
lines: readonly ProductAttributeLine[],
|
|
125
|
+
mf: (namespace: string, key: string) => string | undefined,
|
|
126
|
+
): ResolvedAttributeLine[] {
|
|
127
|
+
const out: ResolvedAttributeLine[] = [];
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
const value = mf(line.namespace, line.key);
|
|
130
|
+
if (value) out.push({ label: line.label, value });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveSpecRows(
|
|
136
|
+
rows: readonly ProductDetailSpecRow[],
|
|
137
|
+
mf: (namespace: string, key: string) => string | undefined,
|
|
138
|
+
): ResolvedSpecRow[] {
|
|
139
|
+
const out: ResolvedSpecRow[] = [];
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
const value = mf(row.namespace, row.key);
|
|
142
|
+
if (value) out.push({ label: row.label, value });
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveFaq(
|
|
148
|
+
faq: NonNullable<ProductDetailsConfig["faq"]>,
|
|
149
|
+
mf: (namespace: string, key: string) => string | undefined,
|
|
150
|
+
): ResolvedFaqItem[] {
|
|
151
|
+
const items: ResolvedFaqItem[] = [];
|
|
152
|
+
for (let n = 1; n <= faq.maxItems; n++) {
|
|
153
|
+
const qKey = faq.questionKeyPattern.replace("{n}", String(n));
|
|
154
|
+
const aKey = faq.answerKeyPattern.replace("{n}", String(n));
|
|
155
|
+
const question = mf(faq.namespace, qKey);
|
|
156
|
+
const answer = mf(faq.namespace, aKey);
|
|
157
|
+
if (question && answer) items.push({ question, answer });
|
|
158
|
+
}
|
|
159
|
+
return items;
|
|
160
|
+
}
|