@forgecart/cli 2.202609221621.0 → 2.202609282317.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.
@@ -1,5 +1,7 @@
1
1
  import { localizedPath } from '../locale/localized-path';
2
2
  import type { LocaleBinding } from '../locale/localized-path';
3
+ import { advertisedPathsByLocale, ownContentRow } from './sidecar';
4
+ import type { SeoSidecar } from './sidecar';
3
5
 
4
6
  /**
5
7
  * The language-first slug law for product URLs (#1347, epic launch#54 W1-9).
@@ -22,15 +24,6 @@ import type { LocaleBinding } from '../locale/localized-path';
22
24
  * done that work.
23
25
  */
24
26
 
25
- /** One language's row in the SEO sidecar (#1341), narrowed to what the law reads. */
26
- export interface SeoLanguageRow {
27
- languageCode: string;
28
- /** Whether this language has real content, as opposed to a derived fallback. */
29
- translated: boolean;
30
- /** The merchant's explicit indexability choice for this language. */
31
- indexable: boolean;
32
- }
33
-
34
27
  /** One language's slug for a product. */
35
28
  export interface ProductTranslationRow {
36
29
  languageCode: string;
@@ -43,28 +36,22 @@ export interface ResolvableProduct {
43
36
  slug: string;
44
37
  /** Every language's slug for this product, translated or not. */
45
38
  translations: readonly ProductTranslationRow[];
46
- seo: { languages: readonly SeoLanguageRow[] };
39
+ seo: SeoSidecar;
47
40
  }
48
41
 
49
42
  /**
50
43
  * The languages that may be ADVERTISED for this product, mapped to their paths.
51
44
  *
52
- * The intersection of two different facts, and taking either alone is a bug:
53
- * `translations` says a language has a SLUG, `seo.languages[].translated` says
54
- * it has CONTENT. A language can have the first without the second — that is
55
- * exactly the fallback copy — and advertising it as an hreflang alternate would
56
- * point a crawler at a derived page and pull it into a reciprocal set it does
57
- * not belong to.
45
+ * A product's ADDRESS is earned per language — `translations` says a language
46
+ * has a SLUG — and the sidecar's intersection rule (`advertisedPathsByLocale`)
47
+ * keeps only the ones that also have CONTENT, so a slug-bearing fallback copy
48
+ * is never advertised as an hreflang alternate.
58
49
  */
59
50
  export function productPathsByLocale(product: ResolvableProduct): Record<string, string> {
60
- const translated = new Set(
61
- product.seo.languages.filter((row) => row.translated).map((row) => row.languageCode),
51
+ const addresses = Object.fromEntries(
52
+ product.translations.map((row) => [row.languageCode, `/products/${row.slug}`]),
62
53
  );
63
- const paths: Record<string, string> = {};
64
- for (const row of product.translations) {
65
- if (translated.has(row.languageCode)) paths[row.languageCode] = `/products/${row.slug}`;
66
- }
67
- return paths;
54
+ return advertisedPathsByLocale(addresses, product.seo);
68
55
  }
69
56
 
70
57
  /** What the route must do with this URL. */
@@ -105,19 +92,13 @@ export function resolveProductPath({
105
92
  return { kind: 'redirect', to: localizedPath(`/products/${product.slug}`, binding) };
106
93
  }
107
94
 
108
- const row = product.seo.languages.find((entry) => entry.languageCode === binding.locale);
109
-
110
- // No row for this language means the shopper is looking at a derived copy —
111
- // real content in another language, shown here so the URL is not a dead end.
112
- // It renders, but it makes no claims: noindex, no hreflang, no canonical.
95
+ // No content of this language's own (`ownContentRow` — a missing row reads
96
+ // as untranslated) means the shopper is looking at a derived copy. It
97
+ // renders, but it makes no claims: noindex, no hreflang, no canonical.
113
98
  // Indexing it would put near-duplicate copy in competition with the language
114
99
  // that genuinely has the content.
115
- //
116
- // A MISSING row is treated exactly like `translated: false`. The sidecar is
117
- // supposed to carry one entry per channel language, so absence means the
118
- // sidecar and the channel disagree — and the safe reading of a disagreement
119
- // is the one that makes no claim.
120
- if (!row || !row.translated) return { kind: 'fallback' };
100
+ const row = ownContentRow(product.seo, binding.locale);
101
+ if (row === null) return { kind: 'fallback' };
121
102
 
122
103
  // Real content in this language. The merchant's own indexability choice is
123
104
  // the last word, and it rides on the resolution rather than being re-derived
@@ -0,0 +1,75 @@
1
+ import type { PathsByLocale } from './alternates';
2
+
3
+ /**
4
+ * What the per-language SEO sidecar says about an entity's COPIES (#1341,
5
+ * ACF pages #1375): which languages hold content of their own, and whether
6
+ * this locale's copy may be indexed.
7
+ *
8
+ * One rule for every entity that carries a sidecar. A product and an ACF page
9
+ * entry ask the same two questions of the same rows, and two spellings of the
10
+ * answer would let a product and a page disagree about what an untranslated
11
+ * language is — one advertising a fallback copy the other refuses to (#1375
12
+ * ruling Q6: pages take product parity).
13
+ *
14
+ * PURE, for the reason every module under `lib/seo/` is: the template-spec
15
+ * imports it by relative path from outside the scaffold, so nothing reachable
16
+ * from here may need the template's Next toolchain.
17
+ */
18
+
19
+ /** One language's row in the sidecar, narrowed to what these claims read. */
20
+ export interface SeoLanguageRow {
21
+ languageCode: string;
22
+ /** Whether this language has real content, as opposed to a derived fallback. */
23
+ translated: boolean;
24
+ /** The merchant's explicit indexability choice for this language. */
25
+ indexable: boolean;
26
+ }
27
+
28
+ /** An entity's sidecar, as far as these claims read it. */
29
+ export interface SeoSidecar {
30
+ languages: readonly SeoLanguageRow[];
31
+ }
32
+
33
+ /**
34
+ * This locale's row when the locale holds content of its own, or null when the
35
+ * shopper is looking at a derived fallback copy — real content in another
36
+ * language, shown here so the URL is not a dead end.
37
+ *
38
+ * A MISSING row is treated exactly like `translated: false`. The sidecar is
39
+ * supposed to carry one entry per channel language, so absence means the
40
+ * sidecar and the channel disagree — and the safe reading of a disagreement
41
+ * is the one that makes no claim.
42
+ */
43
+ export function ownContentRow(seo: SeoSidecar, locale: string): SeoLanguageRow | null {
44
+ const row = seo.languages.find((entry) => entry.languageCode === locale);
45
+ return row?.translated ? row : null;
46
+ }
47
+
48
+ /**
49
+ * The languages that may be ADVERTISED for this entity, mapped to their paths:
50
+ * every language that has an address in `addresses` AND content of its own.
51
+ *
52
+ * The intersection of two different facts, and taking either alone is a bug.
53
+ * An address says the language can be REACHED — a product's per-language slug,
54
+ * or a page route that exists in every language the channel offers; the
55
+ * sidecar's `translated` says the language has CONTENT. A language can have the
56
+ * first without the second — that is exactly the fallback copy — and
57
+ * advertising it as an hreflang alternate would point a crawler at a derived
58
+ * page and pull it into a reciprocal set it does not belong to.
59
+ *
60
+ * `indexable: false` does NOT remove a language: the merchant suppressing a
61
+ * page they own leaves the translation in place, so its siblings must keep
62
+ * naming it or the cluster's reciprocity breaks. Indexability is `noindex.ts`'s
63
+ * job, not this map's. The order is the addresses' own.
64
+ */
65
+ export function advertisedPathsByLocale(
66
+ addresses: PathsByLocale,
67
+ seo: SeoSidecar,
68
+ ): Record<string, string> {
69
+ const translated = new Set(
70
+ seo.languages.filter((row) => row.translated).map((row) => row.languageCode),
71
+ );
72
+ return Object.fromEntries(
73
+ Object.entries(addresses).filter(([languageCode]) => translated.has(languageCode)),
74
+ );
75
+ }
@@ -0,0 +1,98 @@
1
+ import 'server-only';
2
+
3
+ import { cache } from 'react';
4
+
5
+ import { getShopClient } from '../forgecart';
6
+ import type { SiteVerificationMeta } from './metadata';
7
+
8
+ /**
9
+ * What the channel's SEO providers need this storefront to serve (#1530).
10
+ *
11
+ * A provider proves the merchant owns this address by asking them to place a
12
+ * token on it. The platform mints that token per account and per origin, and
13
+ * this read is the storefront's half of the handshake: it asks the shop API
14
+ * what to serve, and `metadata.ts` turns each descriptor into one
15
+ * `<meta name content>` tag. Nothing here knows which provider minted what —
16
+ * the NAME arrives in the descriptor, so a second provider is a server change
17
+ * and no template change at all.
18
+ *
19
+ * Only `META` placements are served. The mechanism rides in the descriptor
20
+ * precisely so that a provider verifying some other way — a DNS record, a file
21
+ * at a path this template does not route — cannot have its token silently
22
+ * rendered as a meta tag, which would be a placement that looks made and is
23
+ * not.
24
+ *
25
+ * PER REQUEST, and no further. React's `cache()` collapses the callers of one
26
+ * render into a single read; there is deliberately no TTL and no process memo
27
+ * above it. A verifier makes ONE fetch, and a token re-minted while a replica
28
+ * still serves a cached copy of the old one is a verification that fails with
29
+ * every part of the system reporting health — the same reason
30
+ * `sitemap-cache.ts`'s window is safe (a stale URL list costs a crawl) and one
31
+ * here would not be.
32
+ *
33
+ * Failure is ALWAYS an empty list, never a throw. This read happens inside the
34
+ * root layout's `generateMetadata`, structurally outside every Suspense
35
+ * boundary (D8), so a throw here is a 500 on every route of the store — and
36
+ * what is lost instead is one meta tag whose absence the platform already
37
+ * detects and retries on its next sync. An unconfigured scaffold reaches the
38
+ * same arm: `getShopClient()` throws when `.env` carries no coordinates, and
39
+ * the prewarm contract says a storefront with no configuration must SERVE.
40
+ */
41
+
42
+ /** The answer whenever there is nothing honest to serve. */
43
+ const NONE: readonly SiteVerificationMeta[] = [];
44
+
45
+ /**
46
+ * The one mechanism this template can satisfy — a tag in the document it
47
+ * renders. Compared against the descriptor's own value rather than assumed.
48
+ */
49
+ const META_METHOD = 'META';
50
+
51
+ /**
52
+ * How long the document shell may wait for this read.
53
+ *
54
+ * The same bound, for the same reason, as the channel read in
55
+ * `locale/channel-locales-loader.ts`: the shell has no Suspense boundary above
56
+ * it, so an unbounded wait holds the whole document open with nothing to time
57
+ * it out. Past this the storefront serves without the tag, which is the lesser
58
+ * of the two failures by a wide margin.
59
+ */
60
+ const VERIFICATION_READ_TIMEOUT_MS = 2_000;
61
+
62
+ /**
63
+ * The placements this storefront must serve, for THIS request.
64
+ *
65
+ * Read through the generated SDK like every other shop-API call in the
66
+ * template — there are no hand-written query documents here, which is what
67
+ * keeps the storefront's reads and the API's schema from drifting apart.
68
+ *
69
+ * The request's own client is used rather than one pinned to the channel
70
+ * default: the answer is the same in every language, so the cheaper of two
71
+ * correct options wins, and that is the socket this render already has open.
72
+ */
73
+ export const getSiteVerifications = cache(async (): Promise<readonly SiteVerificationMeta[]> => {
74
+ let timer: ReturnType<typeof setTimeout> | undefined;
75
+ try {
76
+ return await Promise.race([
77
+ readPlacements(),
78
+ new Promise<readonly SiteVerificationMeta[]>((resolve) => {
79
+ timer = setTimeout(() => {
80
+ resolve(NONE);
81
+ }, VERIFICATION_READ_TIMEOUT_MS);
82
+ }),
83
+ ]);
84
+ } catch {
85
+ // The template's sanctioned catch: an external SDK call, wrapped at its
86
+ // call site, exactly as `forgecart.ts#runAccountOperation` and
87
+ // `shop-session.ts` do. Nothing is swallowed that anyone here could act on
88
+ // — the platform's sync ladder is what observes an unserved placement.
89
+ return NONE;
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ });
94
+
95
+ async function readPlacements(): Promise<readonly SiteVerificationMeta[]> {
96
+ const { seoSiteVerifications } = await (await getShopClient()).seo.seoSiteVerifications();
97
+ return seoSiteVerifications.filter((placement) => placement.method === META_METHOD);
98
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The revision this server is SERVING, for the dev-only error beacon (#847).
3
+ *
4
+ * A browser error report used to be stamped with whatever revision was live at
5
+ * the moment the pod RECEIVED it (`storefront-beacon.service.ts`, `atRevision:
6
+ * this.target.getLiveRevision()`). A promote that advances between serve and
7
+ * throw therefore made the report blame the NEW revision for the OLD one's
8
+ * crash, and the receiver's same-revision fold then marked a healthy tree
9
+ * DEGRADED. The cure is to resolve the revision where the page is RENDERED —
10
+ * here — stamp it into the HTML, and let the report carry it back.
11
+ *
12
+ * WHERE THE RAW VALUE COMES FROM (settled by ruling ANSWER-0425Z-agent-004):
13
+ * the pod's supervisor publishes the serving sha to a DERIVED, read-only file
14
+ * inside the workspace's `.forge/` island by the same act that re-publishes its
15
+ * runtime state, and this module reads that file per request. Explicitly NOT a
16
+ * beacon hop on the render path, and explicitly NOT an environment variable —
17
+ * one dev-server process outlives many promotes, so a value handed to it at
18
+ * spawn time could only ever name the revision it BOOTED at, which is this
19
+ * module's own bug reintroduced one layer down. The retired
20
+ * `.forge/liveRevision` sentinel (appendix design law 5, §7.3) is not that file
21
+ * and is not read here; see `FORGE_SERVING_REVISION_FILE` below.
22
+ *
23
+ * The split is still the shape of the feature:
24
+ *
25
+ * - `parseLiveRevision` is PURE and fully specified by vectors. It owns the
26
+ * whole question of what counts as a revision, independently of where the
27
+ * raw value came from.
28
+ * - `readLiveRevisionSource` is the ONE impure line — the file read, and
29
+ * nothing else in the template touches the filesystem for this.
30
+ *
31
+ * FAIL OPEN, ALWAYS. Every unresolvable case answers `null`, which means no
32
+ * meta tag, which means an untagged report, which means the receiver keeps its
33
+ * existing behaviour (confirm probe included). A page must never fail to render
34
+ * because the revision is unknown, so there is no throw on this path: an
35
+ * unknown revision is an ordinary, expected state — every non-storefront pod,
36
+ * and every pod before its bootstrap primes the ref — not a fault.
37
+ */
38
+
39
+ import { readFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+
42
+ /**
43
+ * The meta tag's `name`. The CONTRACT between this module (which resolves the
44
+ * value), `app/layout.tsx` (which emits the tag) and
45
+ * `components/ForgeErrorBeacon.tsx` (which reads it back out of the DOM at send
46
+ * time). The beacon is a CLIENT component and must not import a `src/server/`
47
+ * module, so it repeats the literal with a comment pointing here — the same way
48
+ * the `/__forge_beacon` URL is repeated across this feature's three producers
49
+ * rather than shared through an import.
50
+ */
51
+ export const FORGE_REVISION_META = 'forge-revision';
52
+
53
+ /**
54
+ * The workspace-relative path the pod's supervisor publishes the serving sha to.
55
+ *
56
+ * MIRRORS `SERVING_REVISION_FILE` in
57
+ * `app/workspace-manager/src/supervisor/workspace-supervisor.service.ts`, whose
58
+ * doc carries the full reasoning for the path — `.forge/` because that island is
59
+ * the board repo's ignore authority and a file anywhere else would dirty the
60
+ * serving tree on every publish, and NEITHER retired legacy path, both of which
61
+ * the pod's own bootstrap deletes at boot. It is repeated here instead of
62
+ * imported because this template is scaffolding DATA — copied verbatim into a
63
+ * merchant's project, with its own `package.json` and toolchain, outside that
64
+ * monorepo's build graph — exactly like the `/__forge_beacon` URL. Nothing in a
65
+ * compiler connects the two copies: a rename on either side would silently stop
66
+ * the file from being found and every report would go back to untagged, so
67
+ * `tool/storefront-template-spec/src/live-revision.spec.ts` compares them.
68
+ *
69
+ * Resolved against `process.cwd()`, which IS the workspace root the supervisor
70
+ * writes into: it spawns `npm run dev` with `cwd` = the configured workspace
71
+ * path (resolved in `process-supervisor.service.ts`, handed to the child in
72
+ * `managed-process.ts`), and `npm` runs the script from the package root without
73
+ * moving it. `lib/seo/scaffolded-routes.ts` already reads a workspace-relative
74
+ * path this way on a request path.
75
+ */
76
+ export const FORGE_SERVING_REVISION_FILE = '.forge/serving-revision';
77
+
78
+ /**
79
+ * A revision is a full git commit sha and nothing else: exactly 40 hex digits,
80
+ * case-insensitive, surrounding whitespace trimmed. That is precisely what the
81
+ * supervisor's source produces — `git rev-parse --verify refs/heads/serving^{commit}`
82
+ * (`board-git.service.ts`) — so anything else reaching here is not a revision
83
+ * and must not be stamped.
84
+ *
85
+ * The rejections are the load-bearing half, because a stamped non-sha is worse
86
+ * than no stamp at all: the receiver compares the payload's value to the live
87
+ * revision by EQUALITY to decide whether to fold without probing, so a
88
+ * placeholder that could also BE the live value would skip the probe on
89
+ * evidence that means nothing. `'unversioned'` is the concrete instance of that
90
+ * hazard — it is literally what the supervisor reports for a pod with no board
91
+ * repo, so an unversioned pod stamping `'unversioned'` would match itself and
92
+ * defeat the guard. Rejecting it here is what makes the receiver's equality
93
+ * check safe.
94
+ *
95
+ * @param raw the candidate revision, exactly as obtained from the source
96
+ * @returns the normalised lowercase sha, or `null` when `raw` is not one
97
+ */
98
+ export function parseLiveRevision(raw: string): string | null {
99
+ const trimmed = raw.trim();
100
+ if (!/^[0-9a-f]{40}$/i.test(trimmed)) return null;
101
+ return trimmed.toLowerCase();
102
+ }
103
+
104
+ /**
105
+ * Obtain the raw revision value — THE ONE impure line in this module.
106
+ *
107
+ * A plain read of {@link FORGE_SERVING_REVISION_FILE}, the file the pod's
108
+ * supervisor re-publishes on every serving advance. There is deliberately NO
109
+ * environment fallback: the ruling excluded one, and an env value read here
110
+ * would be strictly worse than nothing, because the only process that could
111
+ * supply it is the dev-server's own spawn — which happens once and then serves
112
+ * every later promote under the revision it started at.
113
+ *
114
+ * Read per CALL, never captured at module scope, for the same reason:
115
+ * a value snapshotted at first import would keep stamping the boot revision
116
+ * forever. `lib/seo/sitemap-cache.ts` carries the same getter-not-a-constant
117
+ * reasoning.
118
+ *
119
+ * SYNCHRONOUS, unlike `lib/seo/scaffolded-routes.ts`, which reads on a request
120
+ * path asynchronously and explains why. That reasoning does not transfer: it
121
+ * fans out over a directory of arbitrarily many JSON fragments, whereas this is
122
+ * ONE page-cached read of at most 41 bytes, sitting beside an awaited channel
123
+ * read in the same `generateMetadata` that dwarfs it. Going async would cost
124
+ * `resolveLiveRevision` its synchronous signature for nothing measurable.
125
+ *
126
+ * Every failure answers `null`, and the ordinary case IS a failure: the file is
127
+ * absent on every pod before its first publish, on a non-storefront workspace
128
+ * that publishes none, and in a merchant's own checkout of this template, where
129
+ * no supervisor exists at all. Absent, unreadable and garbage all mean the same
130
+ * thing here — no tag, an untagged report, the receiver's existing behaviour —
131
+ * so there is nothing worth distinguishing between them and nothing worth
132
+ * throwing over on a render path.
133
+ */
134
+ export function readLiveRevisionSource(): string | null {
135
+ try {
136
+ return readFileSync(join(process.cwd(), FORGE_SERVING_REVISION_FILE), 'utf8');
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The revision to stamp into this response, or `null` when it cannot be
144
+ * resolved. Composes the source and the acceptance rule and holds no policy of
145
+ * its own, so the ruling can move the source without touching any caller.
146
+ *
147
+ * The DEV-ONLY gate lives at the call site (`app/layout.tsx`), not here — the
148
+ * placement every other file in this feature uses (`ForgeErrorBeacon.tsx`,
149
+ * `app/%5F%5Fforge_beacon/route.ts` and `instrumentation.ts` each check
150
+ * `NODE_ENV` at their own top), which is what lets a production build
151
+ * dead-code-eliminate the whole path rather than merely reach a function that
152
+ * answers `null`.
153
+ */
154
+ export function resolveLiveRevision(): string | null {
155
+ const raw = readLiveRevisionSource();
156
+ if (raw === null) return null;
157
+ return parseLiveRevision(raw);
158
+ }