@forgecart/cli 2.202610052310.0 → 2.202610060357.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- package/templates/template-set.json +10 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marketing signals a landing URL carries (#1596).
|
|
3
|
+
*
|
|
4
|
+
* A shopper arriving from an ad brings the entire attribution story in the
|
|
5
|
+
* query string and nowhere else: the platform's CLICK ID identifies that exact
|
|
6
|
+
* click to the platform's conversion API, and the UTM tags say which campaign
|
|
7
|
+
* paid for it. Both are gone the moment the visitor reaches a second page — no
|
|
8
|
+
* cookie, no header and no backend record carries them — so they are read on
|
|
9
|
+
* landing or they are lost.
|
|
10
|
+
*
|
|
11
|
+
* This module only READS them. It turns a query string into what it found;
|
|
12
|
+
* WHERE those values go — the identity mutation for the click IDs, the track
|
|
13
|
+
* event for the UTM tags — is the caller's decision, taken somewhere that has a
|
|
14
|
+
* session and can talk to the backend. Keeping the two apart is what lets the
|
|
15
|
+
* reading rules be a table of vectors rather than a browser scenario.
|
|
16
|
+
*
|
|
17
|
+
* PURE by construction, and that is a requirement rather than a preference: it
|
|
18
|
+
* imports nothing, touches no browser global and reads no environment. This
|
|
19
|
+
* template tree sits outside the repository's lint, typecheck and test graph
|
|
20
|
+
* (`eslint.config.mts` ignores it, `package/cli/tsconfig.lib.json` excludes it)
|
|
21
|
+
* and is copied verbatim into every merchant's store, so
|
|
22
|
+
* `tool/storefront-template-spec` — which imports modules like this one by
|
|
23
|
+
* relative path — is the only thing standing between this file and production.
|
|
24
|
+
* A module that stops being pure stops being testable from there, which is an
|
|
25
|
+
* alarm rather than an inconvenience.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** A captured click ID, shaped for the backend's `MarketingIdentifierInput`. */
|
|
29
|
+
export interface MarketingIdentifier {
|
|
30
|
+
/** The registered identifier key this value is stored under. */
|
|
31
|
+
key: string;
|
|
32
|
+
/** The click ID exactly as the ad platform wrote it into the URL. */
|
|
33
|
+
value: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The captured UTM tags, shaped to be spread straight into `TrackEventInput`.
|
|
38
|
+
*
|
|
39
|
+
* Every field is optional and an absent tag is OMITTED rather than set to an
|
|
40
|
+
* empty string, because those are different claims: the backend treats a
|
|
41
|
+
* present `''` as a blank dimension and writes it to ClickHouse, where it can
|
|
42
|
+
* no longer be told apart from a campaign that genuinely has no medium.
|
|
43
|
+
*/
|
|
44
|
+
export interface UtmParams {
|
|
45
|
+
utmSource?: string;
|
|
46
|
+
utmMedium?: string;
|
|
47
|
+
utmCampaign?: string;
|
|
48
|
+
utmTerm?: string;
|
|
49
|
+
utmContent?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a landing URL turned out to carry.
|
|
54
|
+
*
|
|
55
|
+
* A discriminated union rather than a pair of possibly-empty collections,
|
|
56
|
+
* because "this visitor came from an ad" and "this visitor typed the address"
|
|
57
|
+
* are different situations and the second is by far the common one. The union
|
|
58
|
+
* makes the empty case impossible to walk past: nothing can reach `identifiers`
|
|
59
|
+
* or `utm` without answering the question first, so a visitor with no query
|
|
60
|
+
* parameters produces exactly the tracking behaviour that existed before this
|
|
61
|
+
* module — no extra request, no blank row.
|
|
62
|
+
*
|
|
63
|
+
* The `captured` arm carries at least one signal, though not necessarily both:
|
|
64
|
+
* an ad link with a click ID and no tags yields an empty `utm`, and a
|
|
65
|
+
* hand-written newsletter link yields empty `identifiers`. A caller skips the
|
|
66
|
+
* empty side rather than sending it.
|
|
67
|
+
*/
|
|
68
|
+
export type MarketingParams =
|
|
69
|
+
| { kind: 'none' }
|
|
70
|
+
| { kind: 'captured'; identifiers: readonly MarketingIdentifier[]; utm: UtmParams };
|
|
71
|
+
|
|
72
|
+
/** One click ID: what the platform appends, and what we store it as. */
|
|
73
|
+
interface ClickIdParam {
|
|
74
|
+
/** The parameter name an ad platform writes into the landing URL. */
|
|
75
|
+
readonly param: string;
|
|
76
|
+
/** The backend identifier key it is stored under. */
|
|
77
|
+
readonly key: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One UTM tag: the URL parameter, and the field it becomes. */
|
|
81
|
+
interface UtmParam {
|
|
82
|
+
readonly param: string;
|
|
83
|
+
readonly field: keyof UtmParams;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The click IDs this storefront captures.
|
|
88
|
+
*
|
|
89
|
+
* Each `key` is byte-identical to one the backend pre-registers, in
|
|
90
|
+
* `package/core/src/marketing-identity/marketing-identity.module.ts:85`, and
|
|
91
|
+
* that exactness is load-bearing: the shop mutation looks the key up and
|
|
92
|
+
* REJECTS an unknown one — reporting it in `rejected` rather than failing — so
|
|
93
|
+
* a single typo here loses the attribution silently, behind a response that
|
|
94
|
+
* still looks accepted.
|
|
95
|
+
*
|
|
96
|
+
* Three of the six registered click IDs appear below. The other three
|
|
97
|
+
* (`msclkid`, `liClickId`, `impact_click_id`) are registered backend-side, but
|
|
98
|
+
* an identifier's KEY and its URL-parameter spelling are two separate facts,
|
|
99
|
+
* and for those three they are not the same string — two are not even
|
|
100
|
+
* URL-shaped. Guessing the missing spellings would buy nothing and could cost
|
|
101
|
+
* real data: a guess that never matches is dead code, and a guess that happens
|
|
102
|
+
* to match another platform's parameter writes a foreign click's value into a
|
|
103
|
+
* real shopper's identity. Capturing one of them later is one row here, which
|
|
104
|
+
* is why the two spellings are held apart even where they coincide.
|
|
105
|
+
*/
|
|
106
|
+
const CLICK_ID_PARAMS: readonly ClickIdParam[] = [
|
|
107
|
+
{ param: 'gclid', key: 'gclid' },
|
|
108
|
+
{ param: 'fbclid', key: 'fbclid' },
|
|
109
|
+
{ param: 'ttclid', key: 'ttclid' },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* All five UTM tags, not the three everyone names.
|
|
114
|
+
*
|
|
115
|
+
* `utm_term` and `utm_content` are the two dropped as an afterthought, and
|
|
116
|
+
* dropping them here would be a pure loss for zero saving: the chain downstream
|
|
117
|
+
* already carries five, from `TrackEventInput` through the relay's allowlist in
|
|
118
|
+
* `src/app/__fc/track/route.ts` to the ClickHouse columns and the
|
|
119
|
+
* `MarketingTouchpoint` row. They are also what tells two ads in one ad group
|
|
120
|
+
* apart, which is the granularity a merchant actually optimises at.
|
|
121
|
+
*/
|
|
122
|
+
const UTM_PARAMS: readonly UtmParam[] = [
|
|
123
|
+
{ param: 'utm_source', field: 'utmSource' },
|
|
124
|
+
{ param: 'utm_medium', field: 'utmMedium' },
|
|
125
|
+
{ param: 'utm_campaign', field: 'utmCampaign' },
|
|
126
|
+
{ param: 'utm_term', field: 'utmTerm' },
|
|
127
|
+
{ param: 'utm_content', field: 'utmContent' },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Unicode control characters (C0, DEL, C1), stripped from every value.
|
|
132
|
+
*
|
|
133
|
+
* The same expression the server applies in
|
|
134
|
+
* `package/core/src/marketing-event/service/marketing-event.service.ts:38`.
|
|
135
|
+
* Repeating it here is not belt-and-braces — it is what keeps the value SENT
|
|
136
|
+
* identical to the value STORED, so the two can never be reported differently.
|
|
137
|
+
*/
|
|
138
|
+
const CONTROL_CHARS_REGEX = /\p{Cc}/gu;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The cap the server applies to every UTM value (that same service, line 39),
|
|
142
|
+
* mirrored here so a tag cannot change shape in transit. These land in
|
|
143
|
+
* `LowCardinality(String)` ClickHouse columns, where the cap is what stops
|
|
144
|
+
* client garbage from inflating the column dictionary; truncating at the same
|
|
145
|
+
* boundary means the campaign name a merchant reads in a report is the one this
|
|
146
|
+
* module sent.
|
|
147
|
+
*/
|
|
148
|
+
export const UTM_VALUE_MAX_LENGTH = 128;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The cap for a click ID — deliberately NOT the UTM one.
|
|
152
|
+
*
|
|
153
|
+
* There is nothing to mirror here: an identifier value lands in an unbounded
|
|
154
|
+
* `varchar` and the identity path validates the KEY while storing the value
|
|
155
|
+
* exactly as given, so this bound is a garbage fence rather than a truncation
|
|
156
|
+
* the server would reproduce. It therefore has to sit well clear of every real
|
|
157
|
+
* click ID: Meta's `fbclid` routinely runs past 128 characters, and a click ID
|
|
158
|
+
* short by even one character matches nothing at the platform that issued it.
|
|
159
|
+
* That failure is silent — the conversion simply never attributes — and
|
|
160
|
+
* preventing it is the reason this module exists.
|
|
161
|
+
*/
|
|
162
|
+
export const CLICK_ID_VALUE_MAX_LENGTH = 512;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read the marketing signals out of a landing URL's query string.
|
|
166
|
+
*
|
|
167
|
+
* Accepts the string with or without its leading `?`, so `window.location.
|
|
168
|
+
* search` and a hand-built query read alike. Never throws: this runs on the
|
|
169
|
+
* first render of every page a visitor can land on, and an unreadable query is
|
|
170
|
+
* a reason to capture nothing, never a reason to fail the page the shopper came
|
|
171
|
+
* for.
|
|
172
|
+
*
|
|
173
|
+
* A repeated parameter keeps its FIRST occurrence. `?gclid=a&gclid=b` has no
|
|
174
|
+
* honest reading — one of the two belongs to somebody else's click — and the
|
|
175
|
+
* first is the one the ad platform appended before any later redirect had the
|
|
176
|
+
* chance to add its own.
|
|
177
|
+
*/
|
|
178
|
+
export function parseMarketingParams(search: string): MarketingParams {
|
|
179
|
+
const params = new URLSearchParams(search);
|
|
180
|
+
|
|
181
|
+
const identifiers: MarketingIdentifier[] = [];
|
|
182
|
+
for (const { param, key } of CLICK_ID_PARAMS) {
|
|
183
|
+
const value = sanitize(params.get(param), CLICK_ID_VALUE_MAX_LENGTH);
|
|
184
|
+
if (value === undefined) continue;
|
|
185
|
+
identifiers.push({ key, value });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const utm: UtmParams = {};
|
|
189
|
+
for (const { param, field } of UTM_PARAMS) {
|
|
190
|
+
const value = sanitize(params.get(param), UTM_VALUE_MAX_LENGTH);
|
|
191
|
+
if (value === undefined) continue;
|
|
192
|
+
utm[field] = value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (identifiers.length === 0 && Object.keys(utm).length === 0) return { kind: 'none' };
|
|
196
|
+
return { kind: 'captured', identifiers, utm };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Clean one raw parameter value, or report it absent.
|
|
201
|
+
*
|
|
202
|
+
* The order matters and matches the server's `sanitizeLowCardinalityValue`
|
|
203
|
+
* (`marketing-event.service.ts:343`): strip control characters, trim, drop what
|
|
204
|
+
* remains if it is empty, and only then cap. Capping first would let trailing
|
|
205
|
+
* whitespace eat the budget, and treating a blank as present would send
|
|
206
|
+
* `?utm_source=` upstream as a campaign whose source is the empty string.
|
|
207
|
+
*/
|
|
208
|
+
function sanitize(raw: string | null, maxLength: number): string | undefined {
|
|
209
|
+
if (raw === null) return undefined;
|
|
210
|
+
const stripped = raw.replace(CONTROL_CHARS_REGEX, '').trim();
|
|
211
|
+
if (stripped.length === 0) return undefined;
|
|
212
|
+
return stripped.slice(0, maxLength);
|
|
213
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minor-unit money arithmetic — the one place the ISO-4217 exponent is decided.
|
|
3
|
+
*
|
|
4
|
+
* ForgeCart prices are integers in the currency's smallest denomination, and
|
|
5
|
+
* the divisor is NOT always a hundred: 2500 is $25.00 (two-decimal cents) but
|
|
6
|
+
* ¥2,500 (zero-decimal yen) and BHD 2.500 (three-decimal fils). A hardcoded
|
|
7
|
+
* hundred is therefore wrong for real currencies, silently, and only for
|
|
8
|
+
* merchants outside the two-decimal world.
|
|
9
|
+
*
|
|
10
|
+
* It lives here rather than inside the price formatter because there are now
|
|
11
|
+
* two consumers with different output: the shopper-facing string
|
|
12
|
+
* (`lib/format.ts`) and the machine-readable `Offer.price` a crawler reads
|
|
13
|
+
* (`lib/seo/json-ld.ts`). Those two disagreeing about what 2500 means would
|
|
14
|
+
* publish one price to people and another to search engines — a discrepancy
|
|
15
|
+
* that reads as deceptive pricing, and one nothing in the template would
|
|
16
|
+
* report. One derivation, two consumers, no room to drift.
|
|
17
|
+
*
|
|
18
|
+
* Pure by construction — no SDK types, no `next` — so it is a table of vectors
|
|
19
|
+
* rather than something only a rendered page can exercise.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The currency's fraction digits, from the same CLDR data that renders it.
|
|
24
|
+
*
|
|
25
|
+
* Derived rather than tabulated: a hand-maintained exponent table is a second
|
|
26
|
+
* source of truth that goes stale silently. The fallback of 2 covers a
|
|
27
|
+
* formatter that resolves no digits at all (an unknown code), which is the
|
|
28
|
+
* only case CLDR cannot answer.
|
|
29
|
+
*/
|
|
30
|
+
export function currencyExponent(currency: string): number {
|
|
31
|
+
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency });
|
|
32
|
+
return formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A minor-unit integer as its major-unit value — 2500 USD → 25, 2500 JPY → 2500. */
|
|
36
|
+
export function toMajorUnits(minorUnits: number, currency: string): number {
|
|
37
|
+
return minorUnits / 10 ** currencyExponent(currency);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A minor-unit integer as the decimal string schema.org wants for a price.
|
|
42
|
+
*
|
|
43
|
+
* Fixed to the currency's own digit count, deliberately: `25` and `25.00` are
|
|
44
|
+
* the same number but not the same claim to a price parser, and a
|
|
45
|
+
* zero-decimal currency must not acquire cents it does not have.
|
|
46
|
+
*/
|
|
47
|
+
export function toPriceString(minorUnits: number, currency: string): string {
|
|
48
|
+
const exponent = currencyExponent(currency);
|
|
49
|
+
return (minorUnits / 10 ** exponent).toFixed(exponent);
|
|
50
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { localizedPath } from '../locale/localized-path';
|
|
2
|
+
import type { LocaleBinding } from '../locale/localized-path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Self-canonical + reciprocal hreflang (#1347, epic launch#54 W1-9).
|
|
6
|
+
*
|
|
7
|
+
* PURE, with no env read of its own: the origin arrives as a parameter and the
|
|
8
|
+
* only caller is `metadata.ts`, which composes these claims together with the
|
|
9
|
+
* title, the robots directive and the social card. Routes do not call in here
|
|
10
|
+
* directly — five separate claims about one address, assembled separately, can
|
|
11
|
+
* disagree in ways nothing in the template would notice.
|
|
12
|
+
*
|
|
13
|
+
* Two claims, and both are claims about OTHER URLs, which is what makes them
|
|
14
|
+
* dangerous to derive loosely:
|
|
15
|
+
*
|
|
16
|
+
* - the canonical says "this content lives at exactly this address", which
|
|
17
|
+
* tells a crawler to collapse every other URL that serves it;
|
|
18
|
+
* - each hreflang says "the same content exists, in that language, over
|
|
19
|
+
* there" — and search engines require reciprocity, so an alternate pointing
|
|
20
|
+
* at a page that does not point back is discarded, taking the whole cluster's
|
|
21
|
+
* credibility with it.
|
|
22
|
+
*
|
|
23
|
+
* Reciprocity is therefore achieved BY CONSTRUCTION rather than by asserting
|
|
24
|
+
* it: every locale in the set is rendered from the same `pathsByLocale` map, so
|
|
25
|
+
* each page in a cluster emits an identical alternate set. Two pages built from
|
|
26
|
+
* one map cannot disagree.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Unprefixed path per language that genuinely HAS this content. */
|
|
30
|
+
export type PathsByLocale = Readonly<Record<string, string>>;
|
|
31
|
+
|
|
32
|
+
export interface AlternatesInput {
|
|
33
|
+
/** The locale this page is being rendered in. */
|
|
34
|
+
binding: LocaleBinding;
|
|
35
|
+
/**
|
|
36
|
+
* Every language with real content, mapped to its own unprefixed path.
|
|
37
|
+
*
|
|
38
|
+
* Membership is the ONLY thing that decides which alternates are emitted:
|
|
39
|
+
* a language a merchant has not translated must never appear, or the cluster
|
|
40
|
+
* advertises a page that answers a fallback copy.
|
|
41
|
+
*/
|
|
42
|
+
pathsByLocale: PathsByLocale;
|
|
43
|
+
/** The deployment's public origin, or null when it has none. */
|
|
44
|
+
publicOrigin: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What a page declares about its own address and its siblings. */
|
|
48
|
+
export interface Alternates {
|
|
49
|
+
canonical: string;
|
|
50
|
+
languages: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The alternates for this page, or undefined when it must not make the claim.
|
|
55
|
+
*
|
|
56
|
+
* Undefined in exactly one case: this locale is not in `pathsByLocale`, which
|
|
57
|
+
* means the shopper is looking at a derived fallback copy. Such a page has no
|
|
58
|
+
* address of its own to be canonical about, and pointing hreflang at the
|
|
59
|
+
* languages that DO have the content would invite a crawler to treat the
|
|
60
|
+
* fallback as part of a reciprocal set it is not a member of. Asserting
|
|
61
|
+
* nothing is the honest answer — the same posture the preview deployment takes.
|
|
62
|
+
*/
|
|
63
|
+
export function buildAlternates({
|
|
64
|
+
binding,
|
|
65
|
+
pathsByLocale,
|
|
66
|
+
publicOrigin,
|
|
67
|
+
}: AlternatesInput): Alternates | undefined {
|
|
68
|
+
const ownPath = pathsByLocale[binding.locale];
|
|
69
|
+
if (ownPath === undefined) return undefined;
|
|
70
|
+
|
|
71
|
+
const languages: Record<string, string> = {};
|
|
72
|
+
for (const [languageCode, path] of Object.entries(pathsByLocale)) {
|
|
73
|
+
languages[languageCode] = absolute(
|
|
74
|
+
localizedPath(path, { locale: languageCode, defaultLocale: binding.defaultLocale }),
|
|
75
|
+
publicOrigin,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `x-default` names where an unmatched visitor should land, and the channel's
|
|
80
|
+
// default language is the only defensible answer — it is the copy served
|
|
81
|
+
// unprefixed. Emitted only when that language actually has the content:
|
|
82
|
+
// pointing x-default at a page that does not exist is worse than omitting it.
|
|
83
|
+
const defaultPath = pathsByLocale[binding.defaultLocale];
|
|
84
|
+
if (defaultPath !== undefined) {
|
|
85
|
+
languages['x-default'] = absolute(
|
|
86
|
+
localizedPath(defaultPath, { locale: binding.defaultLocale, defaultLocale: binding.defaultLocale }),
|
|
87
|
+
publicOrigin,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { canonical: absolute(localizedPath(ownPath, binding), publicOrigin), languages };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The path map for a route that exists in EVERY language — the home page, the
|
|
96
|
+
* catalog, and any page a merchant scaffolds.
|
|
97
|
+
*
|
|
98
|
+
* Structural routes are not translated content: `/products` is the same route
|
|
99
|
+
* in every cluster, so every offered language has it and each one is a genuine
|
|
100
|
+
* alternate. That is the opposite of a product, where membership has to be
|
|
101
|
+
* earned per language (see `productPathsByLocale`). An ACF page sits between
|
|
102
|
+
* the two: its route is this same address in every language, but its content
|
|
103
|
+
* is translated, so the map is narrowed by the page's sidecar before it is
|
|
104
|
+
* advertised (`advertisedPathsByLocale`, #1375).
|
|
105
|
+
*/
|
|
106
|
+
export function staticPathsByLocale(
|
|
107
|
+
languageCodes: readonly string[],
|
|
108
|
+
path: string,
|
|
109
|
+
): Record<string, string> {
|
|
110
|
+
return Object.fromEntries(languageCodes.map((languageCode) => [languageCode, path]));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Absolute against the public origin, relative without one.
|
|
115
|
+
*
|
|
116
|
+
* A relative canonical is valid and resolves against the document's own URL, so
|
|
117
|
+
* it stays truthful on a preview host — where an absolute URL would either name
|
|
118
|
+
* an origin we do not have or invent one. Those pages are noindex anyway
|
|
119
|
+
* (`noindex.ts`), so this is belt and braces rather than the primary defence.
|
|
120
|
+
*/
|
|
121
|
+
function absolute(path: string, publicOrigin: string | null): string {
|
|
122
|
+
return publicOrigin === null ? path : `${publicOrigin}${path}`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { localizedPath } from '../locale/localized-path';
|
|
2
|
+
import type { LocaleBinding } from '../locale/localized-path';
|
|
3
|
+
import { toPriceString } from '../money';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Structured data (#1347, epic launch#54 W1-9) — entity data only, ever.
|
|
7
|
+
*
|
|
8
|
+
* JSON-LD is where a storefront is most tempted to assert things it cannot
|
|
9
|
+
* know. `Organization` wants a legal name, a logo and `sameAs` social
|
|
10
|
+
* profiles; `WebSite` wants a `SearchAction` sitelinks searchbox. None of that
|
|
11
|
+
* is derivable from a product row — a template emitting it would be inventing
|
|
12
|
+
* facts about a real business, and an unearned `SearchAction` is a claim about
|
|
13
|
+
* a search endpoint that may not exist. Those types are refused **at the type
|
|
14
|
+
* level** rather than by review: {@link JsonLdNode} is a closed union, so an
|
|
15
|
+
* `Organization` node does not compile, and no amount of later editing can
|
|
16
|
+
* reintroduce one without deleting this sentence first.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is pure — no `next`, no SDK types — so the shapes are a
|
|
19
|
+
* table of vectors instead of something only a rendered page could exercise.
|
|
20
|
+
* Inputs are declared structurally for the same reason.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The only context this template speaks. */
|
|
24
|
+
type SchemaContext = 'https://schema.org';
|
|
25
|
+
|
|
26
|
+
/** schema.org availability, spelled as the full IRIs Google expects. */
|
|
27
|
+
export const IN_STOCK = 'https://schema.org/InStock';
|
|
28
|
+
export const OUT_OF_STOCK = 'https://schema.org/OutOfStock';
|
|
29
|
+
|
|
30
|
+
export interface OfferNode {
|
|
31
|
+
'@type': 'Offer';
|
|
32
|
+
/** A decimal string in the currency's own digits — never a formatted price. */
|
|
33
|
+
price: string;
|
|
34
|
+
priceCurrency: string;
|
|
35
|
+
availability: typeof IN_STOCK | typeof OUT_OF_STOCK;
|
|
36
|
+
sku?: string;
|
|
37
|
+
url: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProductNode {
|
|
41
|
+
'@context': SchemaContext;
|
|
42
|
+
'@type': 'Product';
|
|
43
|
+
name: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
image?: string;
|
|
46
|
+
sku?: string;
|
|
47
|
+
/** The page's canonical — the same URL its `<link rel="canonical">` claims. */
|
|
48
|
+
url: string;
|
|
49
|
+
offers: OfferNode[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ListItemNode {
|
|
53
|
+
'@type': 'ListItem';
|
|
54
|
+
position: number;
|
|
55
|
+
name: string;
|
|
56
|
+
/** Absent on the last crumb: it IS this page, and a self-link adds nothing. */
|
|
57
|
+
item?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BreadcrumbListNode {
|
|
61
|
+
'@context': SchemaContext;
|
|
62
|
+
'@type': 'BreadcrumbList';
|
|
63
|
+
itemListElement: ListItemNode[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PersonNode {
|
|
67
|
+
'@type': 'Person';
|
|
68
|
+
name: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A blog post, as the #1347 allowlist admits it (#1992): every field is entity
|
|
73
|
+
* data the post's own record carries — its name, its dek, its stamps and its
|
|
74
|
+
* cover — and nothing about the business publishing it.
|
|
75
|
+
*/
|
|
76
|
+
export interface BlogPostingNode {
|
|
77
|
+
'@context': SchemaContext;
|
|
78
|
+
'@type': 'BlogPosting';
|
|
79
|
+
headline: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
/** Absolute, or absent: a crawler reads a relative image as no image at all. */
|
|
82
|
+
image?: string;
|
|
83
|
+
/** ISO-8601, as the shop API stamps the post's `createdAt` / `updatedAt`. */
|
|
84
|
+
datePublished: string;
|
|
85
|
+
dateModified: string;
|
|
86
|
+
/** The page's canonical — the same URL its `<link rel="canonical">` claims. */
|
|
87
|
+
url: string;
|
|
88
|
+
author?: PersonNode;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The allowlist, as a type.
|
|
93
|
+
*
|
|
94
|
+
* `Organization`, `WebSite`, `SearchAction`, `logo` and `sameAs` are absent by
|
|
95
|
+
* construction — not filtered at runtime, which would be a check someone can
|
|
96
|
+
* disable, but unrepresentable. `BlogPosting` joined with #1992 for #1041's
|
|
97
|
+
* content routes, which have a post record behind every node; the promote
|
|
98
|
+
* verifier judges each node's SHAPE under `SEO_SCHEMA_BLOGPOSTING_INVALID`.
|
|
99
|
+
*/
|
|
100
|
+
export type JsonLdNode = ProductNode | BreadcrumbListNode | BlogPostingNode;
|
|
101
|
+
|
|
102
|
+
/** One variant, as much of it as structured data has any business knowing. */
|
|
103
|
+
export interface OfferableVariant {
|
|
104
|
+
sku?: string | null;
|
|
105
|
+
priceWithTax: number;
|
|
106
|
+
currencyCode: string;
|
|
107
|
+
stockOnHand: number;
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ProductJsonLdInput {
|
|
112
|
+
name: string;
|
|
113
|
+
description?: string | null;
|
|
114
|
+
image?: string | null;
|
|
115
|
+
canonical: string;
|
|
116
|
+
variants: readonly OfferableVariant[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The `Product` node for a product page.
|
|
121
|
+
*
|
|
122
|
+
* `url` is the CANONICAL, not the requested address: structured data that
|
|
123
|
+
* named a redirecting or query-bearing URL would compete with the page's own
|
|
124
|
+
* canonical for the same content, which is the duplicate-content bug the
|
|
125
|
+
* canonical exists to prevent.
|
|
126
|
+
*
|
|
127
|
+
* Disabled variants are not offered. An offer is a statement that something
|
|
128
|
+
* can be bought, and a disabled variant cannot be.
|
|
129
|
+
*/
|
|
130
|
+
export function productJsonLd({
|
|
131
|
+
name,
|
|
132
|
+
description,
|
|
133
|
+
image,
|
|
134
|
+
canonical,
|
|
135
|
+
variants,
|
|
136
|
+
}: ProductJsonLdInput): ProductNode {
|
|
137
|
+
const offers = variants
|
|
138
|
+
.filter((variant) => variant.enabled)
|
|
139
|
+
.map(
|
|
140
|
+
(variant): OfferNode => ({
|
|
141
|
+
'@type': 'Offer',
|
|
142
|
+
price: toPriceString(variant.priceWithTax, variant.currencyCode),
|
|
143
|
+
priceCurrency: variant.currencyCode,
|
|
144
|
+
availability: variant.stockOnHand > 0 ? IN_STOCK : OUT_OF_STOCK,
|
|
145
|
+
...(variant.sku ? { sku: variant.sku } : {}),
|
|
146
|
+
url: canonical,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
'@context': 'https://schema.org',
|
|
152
|
+
'@type': 'Product',
|
|
153
|
+
name,
|
|
154
|
+
// Omitted rather than emitted empty: an absent field is "not stated", an
|
|
155
|
+
// empty one is a stated blank, and only the first is true here.
|
|
156
|
+
...(description ? { description } : {}),
|
|
157
|
+
...(image ? { image } : {}),
|
|
158
|
+
url: canonical,
|
|
159
|
+
offers,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface BreadcrumbInput {
|
|
164
|
+
binding: LocaleBinding;
|
|
165
|
+
publicOrigin: string;
|
|
166
|
+
/** The trail's leaf — this page, named but not linked. */
|
|
167
|
+
leafName: string;
|
|
168
|
+
/** The structural ancestors, unprefixed; the locale is applied here. */
|
|
169
|
+
trail: readonly { name: string; path: string }[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The `BreadcrumbList` for a page, locale-aware and absolute.
|
|
174
|
+
*
|
|
175
|
+
* The ancestor paths are localized through the same `localizedPath` the visible
|
|
176
|
+
* breadcrumbs use, so the trail a crawler reads and the trail a shopper clicks
|
|
177
|
+
* are the same trail — in the same language. A German page whose structured
|
|
178
|
+
* data pointed at the English catalog would be describing a different site.
|
|
179
|
+
*/
|
|
180
|
+
export function breadcrumbJsonLd({
|
|
181
|
+
binding,
|
|
182
|
+
publicOrigin,
|
|
183
|
+
leafName,
|
|
184
|
+
trail,
|
|
185
|
+
}: BreadcrumbInput): BreadcrumbListNode {
|
|
186
|
+
const itemListElement: ListItemNode[] = trail.map(
|
|
187
|
+
(crumb, index): ListItemNode => ({
|
|
188
|
+
'@type': 'ListItem',
|
|
189
|
+
position: index + 1,
|
|
190
|
+
name: crumb.name,
|
|
191
|
+
item: `${publicOrigin}${localizedPath(crumb.path, binding)}`,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
itemListElement.push({
|
|
196
|
+
'@type': 'ListItem',
|
|
197
|
+
position: trail.length + 1,
|
|
198
|
+
name: leafName,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface BlogPostingJsonLdInput {
|
|
205
|
+
headline: string;
|
|
206
|
+
description?: string | null;
|
|
207
|
+
image?: string | null;
|
|
208
|
+
/** The post's `createdAt` as the shop API stamps it — ISO-8601. */
|
|
209
|
+
publishedAt: string;
|
|
210
|
+
/** The post's `updatedAt`, the same way. */
|
|
211
|
+
updatedAt: string;
|
|
212
|
+
canonical: string;
|
|
213
|
+
authorName?: string | null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** An `http(s)` URL with an origin — the only image a crawler attaches to a post. */
|
|
217
|
+
const ABSOLUTE_HTTP_URL = /^https?:\/\/\S+$/i;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The `BlogPosting` node for a blog post — the ONE such node a post page emits.
|
|
221
|
+
*
|
|
222
|
+
* Every optional field is omitted rather than emitted empty, for the reason
|
|
223
|
+
* {@link productJsonLd} gives, and the image is omitted unless it is absolute:
|
|
224
|
+
* a relative `image` is read by a crawler as no image, and by the promote
|
|
225
|
+
* verifier as a defect (`SEO_SCHEMA_BLOGPOSTING_INVALID`), so emitting it would
|
|
226
|
+
* turn an undescribed cover into a refused promote. The dates pass through as
|
|
227
|
+
* the shop API stamped them — already ISO-8601 — because re-parsing a timestamp
|
|
228
|
+
* to re-print it can only lose precision or throw.
|
|
229
|
+
*/
|
|
230
|
+
export function blogPostingJsonLd({
|
|
231
|
+
headline,
|
|
232
|
+
description,
|
|
233
|
+
image,
|
|
234
|
+
publishedAt,
|
|
235
|
+
updatedAt,
|
|
236
|
+
canonical,
|
|
237
|
+
authorName,
|
|
238
|
+
}: BlogPostingJsonLdInput): BlogPostingNode {
|
|
239
|
+
return {
|
|
240
|
+
'@context': 'https://schema.org',
|
|
241
|
+
'@type': 'BlogPosting',
|
|
242
|
+
headline,
|
|
243
|
+
...(description ? { description } : {}),
|
|
244
|
+
...(image && ABSOLUTE_HTTP_URL.test(image) ? { image } : {}),
|
|
245
|
+
datePublished: publishedAt,
|
|
246
|
+
dateModified: updatedAt,
|
|
247
|
+
url: canonical,
|
|
248
|
+
...(authorName ? { author: { '@type': 'Person', name: authorName } } : {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A node as the text of a `<script type="application/ld+json">`.
|
|
254
|
+
*
|
|
255
|
+
* `<` becomes its JSON unicode escape, and that single substitution is the
|
|
256
|
+
* whole escape rule — necessary AND sufficient. Inside a script element only
|
|
257
|
+
* three sequences change how the parser behaves (`</script`, `<script` and
|
|
258
|
+
* `<!--`), every one of them begins with `<`, and a JSON parser decodes
|
|
259
|
+
* `<` straight back to `<`. So a product description containing
|
|
260
|
+
* `</script>` — rich text from the dashboard, which is exactly where such a
|
|
261
|
+
* string comes from — cannot close the element early and turn the rest of the
|
|
262
|
+
* document into markup.
|
|
263
|
+
*/
|
|
264
|
+
export function serializeJsonLd(node: JsonLdNode): string {
|
|
265
|
+
return JSON.stringify(node).replace(/</g, '\\u003c');
|
|
266
|
+
}
|