@tokenoftrust/storefront-runner 2.0.0 → 2.0.1

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.
@@ -4,13 +4,15 @@ import { readdirSync, existsSync } from "node:fs";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { markSave, reportServerUp } from "./dev-loop-state.mjs";
6
6
  import { isTransientWatchFile } from "../../../scripts/dev/transient-files.mjs";
7
+ import { E2E_TENANT_DIR, tenantDirSegments } from "../../../scripts/lib/tenant-dirs.mjs";
7
8
  import { readFile } from "node:fs/promises";
8
9
 
9
10
  // Repo-root tenants/ dir (colocated tenant layout) + the generated static dest
10
11
  // the predev copy step mirrors public/ into. This module lives at
11
12
  // apps/storefront/dev-plugins/, so tenants/ is three levels up. Forward-slash
12
13
  // normalized to match Vite's ctx.file (Vite always reports posix-style paths).
13
- const TENANTS_DIR = fileURLToPath(new URL("../../../tenants", import.meta.url)).replace(/\\/g, "/");
14
+ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
15
+ const TENANTS_DIR = join(REPO_ROOT, "tenants").replace(/\\/g, "/");
14
16
  const PUBLIC_TENANTS_DEST = fileURLToPath(new URL("../public/tenants", import.meta.url));
15
17
 
16
18
  /**
@@ -53,7 +55,10 @@ async function loadTsModule(server, url) {
53
55
  export function classifyTenantFile(file) {
54
56
  const f = String(file).replace(/\\/g, "/");
55
57
  if (!f.startsWith(TENANTS_DIR + "/")) return null;
56
- const rel = f.slice(TENANTS_DIR.length + 1); // "<id>/<...>"
58
+ // "<id>/<...>" for a real tenant, "e2e/<id>/<...>" for a platform test tenant —
59
+ // consume the nesting segment so `id` is always the tenant's own id.
60
+ let rel = f.slice(TENANTS_DIR.length + 1);
61
+ if (rel.startsWith(E2E_TENANT_DIR + "/")) rel = rel.slice(E2E_TENANT_DIR.length + 1);
57
62
  const slash = rel.indexOf("/");
58
63
  if (slash < 0) return null;
59
64
  const rest = rel.slice(slash + 1);
@@ -97,7 +102,7 @@ export function isVersionedTenant(id) {
97
102
  .map((s) => s.trim())
98
103
  .filter(Boolean);
99
104
  if (envList.includes(id)) return true;
100
- return existsSync(join(TENANTS_DIR, id, ".tot", "assets-versioned"));
105
+ return existsSync(join(REPO_ROOT, ...tenantDirSegments(id), ".tot", "assets-versioned"));
101
106
  }
102
107
 
103
108
  /**
@@ -10639,7 +10639,7 @@ if (invokedPath2) {
10639
10639
  // apps/storefront/scripts/v84CleanCloneGraphPin.ts
10640
10640
  var V84_CLEAN_CLONE_LAUNCHER_ENTRY = "scripts/diag/rerun-v84-clean-clone.sh";
10641
10641
  var V84_CLEAN_CLONE_GRAPH_COUNT = 191;
10642
- var V84_CLEAN_CLONE_GRAPH_SHA256 = "e284ebf9bd5f0795bee4126c5b2e36cf7cbade05fcbcc9b716a35fa9800fa377";
10642
+ var V84_CLEAN_CLONE_GRAPH_SHA256 = "dc9fb2e28b7d8041ca901ae2df204ab34da1eb957cd88f75437361e42491e566";
10643
10643
 
10644
10644
  // apps/storefront/scripts/replay-v84-clean-clone-pass.ts
10645
10645
  var SOURCE3 = "tokenoftrust.com";
@@ -12,7 +12,7 @@ export const V84_CLEAN_CLONE_BUILD_ENTRY =
12
12
  "apps/storefront/scripts/build-v84-clean-clone-standalone.mjs" as const;
13
13
  export const V84_CLEAN_CLONE_GRAPH_COUNT = 191;
14
14
  export const V84_CLEAN_CLONE_GRAPH_SHA256 =
15
- "e284ebf9bd5f0795bee4126c5b2e36cf7cbade05fcbcc9b716a35fa9800fa377" as const;
15
+ "dc9fb2e28b7d8041ca901ae2df204ab34da1eb957cd88f75437361e42491e566" as const;
16
16
 
17
17
  export interface V84CleanCloneGraphIdentity {
18
18
  readonly paths: readonly string[];
@@ -20,7 +20,7 @@
20
20
  * Changing that value flips which tenant a bare/unknown host serves — proof the
21
21
  * default is configurable, not hardcoded.
22
22
  */
23
- import type { TenantResolver } from "@tot/public-runtime";
23
+ import { tenantIdFromPath, type TenantResolver } from "@tot/public-runtime";
24
24
  import { readEnv, readKv } from "@/lib/env";
25
25
  import {
26
26
  tenantResolver as staticResolver,
@@ -32,24 +32,32 @@ import { getTenantTheme } from "@/themes/tenants";
32
32
  import { devTenantsToSeed } from "@/config/devTenantSeed";
33
33
  import { MemoryKv } from "@/lib/memoryKv";
34
34
 
35
- // LOCAL dev loop discovery: enumerate the colocated `tenants/<id>/` dirs on disk,
35
+ // LOCAL dev loop discovery: enumerate the colocated tenant dirs on disk,
36
36
  // INCLUDING a developer's checkout grafted in by `tot dev` (scripts/tot-dev.mjs).
37
37
  // Keys only — the loaders are never invoked. Vite resolves this glob at
38
38
  // dev-server boot (after the graft) and re-resolves on HMR, so a newly grafted
39
39
  // tenant dir is discovered. Mirrors the content/theme dir globs the app already
40
40
  // uses (provider.ts, themes/tenants/index.ts). Depth: from src/config/ the repo
41
41
  // root is four levels up (config→src→storefront→apps→root).
42
+ //
43
+ // Both tiers are globbed: real tenants at `tenants/<id>/`, platform test tenants
44
+ // one level deeper at `tenants/e2e/<id>/` (tenantDirRelative). Vite needs literal
45
+ // patterns, so the nesting is spelled out here and `tenantIdFromPath` — the one
46
+ // inverse of that mapping — recovers the id from either shape.
42
47
  const localTenantFiles = import.meta.glob([
43
48
  "../../../../tenants/*/theme.json",
44
49
  "../../../../tenants/*/content/home.html",
45
50
  "../../../../tenants/*/content/home.json",
51
+ "../../../../tenants/e2e/*/theme.json",
52
+ "../../../../tenants/e2e/*/content/home.html",
53
+ "../../../../tenants/e2e/*/content/home.json",
46
54
  ]);
47
55
 
48
- /** Dir names under `tenants/*` (dir == tenant id), from the discovery glob keys. */
56
+ /** Tenant ids with a local dir (dir == tenant id), from the discovery glob keys. */
49
57
  function localTenantDirIds(): string[] {
50
58
  const ids = new Set<string>();
51
59
  for (const key of Object.keys(localTenantFiles)) {
52
- const id = /\/tenants\/([^/]+)\//.exec(key)?.[1];
60
+ const id = tenantIdFromPath(key);
53
61
  if (id) ids.add(id);
54
62
  }
55
63
  return [...ids];
@@ -80,7 +88,7 @@ function catalogScopes(): Set<string> {
80
88
  // it. The explicit `.tot` literal in the pattern matches the dotdir; if the glob
81
89
  // sees nothing, synthesis simply falls back to the colocation convention.
82
90
  const tenantMarkerFiles = import.meta.glob<{ scope?: string }>(
83
- "../../../../tenants/*/.tot/config.json",
91
+ ["../../../../tenants/*/.tot/config.json", "../../../../tenants/e2e/*/.tot/config.json"],
84
92
  { eager: true, import: "default" },
85
93
  );
86
94
 
@@ -88,7 +96,7 @@ const tenantMarkerFiles = import.meta.glob<{ scope?: string }>(
88
96
  function slugScopes(): Map<string, string> {
89
97
  const map = new Map<string, string>();
90
98
  for (const [key, cfg] of Object.entries(tenantMarkerFiles)) {
91
- const id = /\/tenants\/([^/]+)\//.exec(key)?.[1];
99
+ const id = tenantIdFromPath(key);
92
100
  const scope = cfg?.scope;
93
101
  // A hostname scope that DIFFERS from the dir is the slug override; a scope
94
102
  // equal to the dir (the colocation norm) needs no override.
@@ -1,3 +1,4 @@
1
+ import { tenantDirSegment } from "@tot/public-runtime";
1
2
  import type { AuthorBio, BlogPost, BlogPostSummary, PostStatus } from "./types.js";
2
3
  import type { ContentProvider } from "../storyblok/provider.js";
3
4
 
@@ -21,15 +22,24 @@ export interface Collection {
21
22
  // collection id are both path segments filtered at call time. The derived index
22
23
  // is the set of `posts/*.json` files — there is no hand-maintained index.json.
23
24
  const postGlobs = import.meta.glob<BlogPost>(
24
- "../../../../../tenants/*/content/*/posts/*.json",
25
+ [
26
+ "../../../../../tenants/*/content/*/posts/*.json",
27
+ "../../../../../tenants/e2e/*/content/*/posts/*.json",
28
+ ],
25
29
  { import: "default" },
26
30
  );
27
31
  const redirectGlobs = import.meta.glob<Record<string, string>>(
28
- "../../../../../tenants/*/content/*/redirects.json",
32
+ [
33
+ "../../../../../tenants/*/content/*/redirects.json",
34
+ "../../../../../tenants/e2e/*/content/*/redirects.json",
35
+ ],
29
36
  { import: "default" },
30
37
  );
31
38
  const authorsGlobs = import.meta.glob<Record<string, AuthorBio>>(
32
- "../../../../../tenants/*/content/*/authors.json",
39
+ [
40
+ "../../../../../tenants/*/content/*/authors.json",
41
+ "../../../../../tenants/e2e/*/content/*/authors.json",
42
+ ],
33
43
  { import: "default" },
34
44
  );
35
45
 
@@ -128,7 +138,7 @@ function normalize(raw: BlogPost): BlogPost {
128
138
  }
129
139
 
130
140
  function postsPrefix(tenantId: string, collection: string): string {
131
- return `/tenants/${tenantId}/content/${collection}/posts/`;
141
+ return `${tenantDirSegment(tenantId)}content/${collection}/posts/`;
132
142
  }
133
143
 
134
144
  function toSummary(p: BlogPost): BlogPostSummary {
@@ -162,7 +172,7 @@ export async function listCollections(
162
172
  tenantId: string,
163
173
  content?: ArtifactSource,
164
174
  ): Promise<string[]> {
165
- const marker = `/tenants/${tenantId}/content/`;
175
+ const marker = `${tenantDirSegment(tenantId)}content/`;
166
176
  const collections = new Set<string>();
167
177
  const runtimePaths = canReadArtifacts(content)
168
178
  ? await content.listArtifactPaths("")
@@ -360,7 +370,7 @@ export async function getRedirects(
360
370
  }
361
371
  const key = Object.keys(redirectGlobs).find(
362
372
  (candidate) =>
363
- candidate.includes(`/tenants/${tenantId}/content/${collection}/`) &&
373
+ candidate.includes(`${tenantDirSegment(tenantId)}content/${collection}/`) &&
364
374
  candidate.endsWith("/redirects.json"),
365
375
  );
366
376
  if (!key) return {};
@@ -392,7 +402,7 @@ export async function getAuthors(
392
402
  }
393
403
  const key = Object.keys(authorsGlobs).find(
394
404
  (candidate) =>
395
- candidate.includes(`/tenants/${tenantId}/content/${collection}/`) &&
405
+ candidate.includes(`${tenantDirSegment(tenantId)}content/${collection}/`) &&
396
406
  candidate.endsWith("/authors.json"),
397
407
  );
398
408
  if (!key) return {};
@@ -3,6 +3,7 @@
3
3
  // derived engine lives in ./collection.ts and powers `the-build` + any new
4
4
  // collection; switching `/blog` onto it is a later migration once order is
5
5
  // encoded as data (see B0.1 §"/blog migration caution").
6
+ import { tenantDirSegment } from "@tot/public-runtime";
6
7
  import type { BlogPost, BlogPostSummary } from "./types.js";
7
8
  import type { ContentProvider } from "../storyblok/provider.js";
8
9
 
@@ -12,21 +13,33 @@ function canReadArtifacts(value: ArtifactSource | undefined): value is ArtifactS
12
13
  return typeof value?.listArtifactPaths === "function" && typeof value.readArtifact === "function";
13
14
  }
14
15
 
16
+ // Both tenant tiers: real at `tenants/<id>/`, platform test one level deeper at
17
+ // `tenants/e2e/<id>/`. `tenantDirSegment` is the one inverse used to look a
18
+ // tenant's keys back up.
15
19
  const indexGlobs = import.meta.glob<BlogPostSummary[]>(
16
- "../../../../../tenants/*/content/blog/index.json",
20
+ [
21
+ "../../../../../tenants/*/content/blog/index.json",
22
+ "../../../../../tenants/e2e/*/content/blog/index.json",
23
+ ],
17
24
  { import: "default" },
18
25
  );
19
26
  const postGlobs = import.meta.glob<BlogPost>(
20
- "../../../../../tenants/*/content/blog/posts/*.json",
27
+ [
28
+ "../../../../../tenants/*/content/blog/posts/*.json",
29
+ "../../../../../tenants/e2e/*/content/blog/posts/*.json",
30
+ ],
21
31
  { import: "default" },
22
32
  );
23
33
  const redirectGlobs = import.meta.glob<Record<string, string>>(
24
- "../../../../../tenants/*/content/blog/redirects.json",
34
+ [
35
+ "../../../../../tenants/*/content/blog/redirects.json",
36
+ "../../../../../tenants/e2e/*/content/blog/redirects.json",
37
+ ],
25
38
  { import: "default" },
26
39
  );
27
40
 
28
41
  function tenantKey(keys: string[], tenantId: string, suffix: string): string | undefined {
29
- const segment = `/tenants/${tenantId}/content/blog/`;
42
+ const segment = `${tenantDirSegment(tenantId)}content/blog/`;
30
43
  return keys.find((key) => key.includes(segment) && key.endsWith(suffix));
31
44
  }
32
45
 
@@ -47,7 +60,7 @@ export async function listBlogPosts(
47
60
  if (paths !== null) return [];
48
61
  }
49
62
  const key = tenantKey(Object.keys(indexGlobs), tenantId, "/index.json")
50
- ?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/index.json`));
63
+ ?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/index.json`));
51
64
  return key ? indexGlobs[key]!() : [];
52
65
  }
53
66
 
@@ -91,7 +104,7 @@ export async function resolveBlogRedirect(
91
104
  if (paths !== null) return null;
92
105
  }
93
106
  const key = tenantKey(Object.keys(redirectGlobs), tenantId, "/redirects.json")
94
- ?? Object.keys(redirectGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/redirects.json`));
107
+ ?? Object.keys(redirectGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/redirects.json`));
95
108
  if (!key) return null;
96
109
  const redirects = await redirectGlobs[key]!();
97
110
  return redirects[slug] ?? null;
@@ -24,6 +24,8 @@ import {
24
24
  contentArtifactPath,
25
25
  listPublishedArtifactPaths,
26
26
  readPublishedArtifact,
27
+ tenantDirRelative,
28
+ tenantDirSegment,
27
29
  } from "@tot/public-runtime";
28
30
  import { renderRawChrome } from "../rawChrome.js";
29
31
  import { parseChromeConfig } from "../chrome/parseConfig.js";
@@ -86,41 +88,65 @@ export interface ContentProvider {
86
88
  // time; a file missing for a tenant falls back to the default scope, so a
87
89
  // partially-adopted tenant still renders. Depth: from src/lib/storyblok/ the
88
90
  // repo root is five levels up (storyblok→lib→src→storefront→apps→root).
89
- const homeGlobs = import.meta.glob<HomeContent>("../../../../../tenants/*/content/home.json", {
91
+ const homeGlobs = import.meta.glob<HomeContent>([
92
+ "../../../../../tenants/*/content/home.json",
93
+ "../../../../../tenants/e2e/*/content/home.json",
94
+ ], {
90
95
  import: "default",
91
96
  });
92
- const chromeGlobs = import.meta.glob<unknown>("../../../../../tenants/*/content/chrome.json", {
97
+ const chromeGlobs = import.meta.glob<unknown>([
98
+ "../../../../../tenants/*/content/chrome.json",
99
+ "../../../../../tenants/e2e/*/content/chrome.json",
100
+ ], {
93
101
  import: "default",
94
102
  });
95
103
  // Per-page header/footer variant overrides for the raw-HTML chrome path (see
96
104
  // lib/chrome/assignments.ts). Tenant-owned, no cross-tenant fallback — same
97
105
  // convention as chromeGlobs above.
98
106
  const chromeAssignmentGlobs = import.meta.glob<unknown>(
99
- "../../../../../tenants/*/content/chrome-assignments.json",
107
+ [
108
+ "../../../../../tenants/*/content/chrome-assignments.json",
109
+ "../../../../../tenants/e2e/*/content/chrome-assignments.json",
110
+ ],
100
111
  { import: "default" },
101
112
  );
102
- const pageGlobs = import.meta.glob<EditorialPage>("../../../../../tenants/*/content/pages/*.json", {
113
+ const pageGlobs = import.meta.glob<EditorialPage>([
114
+ "../../../../../tenants/*/content/pages/*.json",
115
+ "../../../../../tenants/e2e/*/content/pages/*.json",
116
+ ], {
103
117
  import: "default",
104
118
  });
105
119
  // Raw self-authored home documents (marketing tenants). `?raw` bundles the file
106
120
  // as a string; a tenant with no home.html simply has no entry here.
107
- const homeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/home.html", {
121
+ const homeHtmlGlobs = import.meta.glob<string>([
122
+ "../../../../../tenants/*/content/home.html",
123
+ "../../../../../tenants/e2e/*/content/home.html",
124
+ ], {
108
125
  query: "?raw",
109
126
  import: "default",
110
127
  });
111
128
  // Raw self-authored SUBPAGE documents: tenants/<tenant>/content/pages-html/<slug>.html
112
129
  // (e.g. pages-html/pricing.html -> /pricing/). `**` supports nested slugs.
113
- const pageHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/pages-html/**/*.html", {
130
+ const pageHtmlGlobs = import.meta.glob<string>([
131
+ "../../../../../tenants/*/content/pages-html/**/*.html",
132
+ "../../../../../tenants/e2e/*/content/pages-html/**/*.html",
133
+ ], {
114
134
  query: "?raw",
115
135
  import: "default",
116
136
  });
117
137
  // Shared-chrome wrapper document (marketing tenants that author body-only page
118
138
  // fragments). One per tenant; a tenant without it keeps serving full documents.
119
- const chromeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/chrome.html", {
139
+ const chromeHtmlGlobs = import.meta.glob<string>([
140
+ "../../../../../tenants/*/content/chrome.html",
141
+ "../../../../../tenants/e2e/*/content/chrome.html",
142
+ ], {
120
143
  query: "?raw",
121
144
  import: "default",
122
145
  });
123
- const artifactGlobs = import.meta.glob<string>("../../../../../tenants/*/content/**/*", {
146
+ const artifactGlobs = import.meta.glob<string>([
147
+ "../../../../../tenants/*/content/**/*",
148
+ "../../../../../tenants/e2e/*/content/**/*",
149
+ ], {
124
150
  query: "?raw",
125
151
  import: "default",
126
152
  });
@@ -131,13 +157,13 @@ function pickContent<T>(
131
157
  globs: Record<string, () => Promise<T>>,
132
158
  scope: string,
133
159
  ): (() => Promise<T>) | null {
134
- const seg = `/tenants/${scope}/content/`;
160
+ const seg = `${tenantDirSegment(scope)}content/`;
135
161
  const key = Object.keys(globs).find((k) => k.includes(seg));
136
162
  return (key ? globs[key] : null) ?? null;
137
163
  }
138
164
 
139
165
  function pickPages(scope: string): Array<() => Promise<EditorialPage>> {
140
- const seg = `/tenants/${scope}/content/pages/`;
166
+ const seg = `${tenantDirSegment(scope)}content/pages/`;
141
167
  return Object.keys(pageGlobs)
142
168
  .filter((k) => k.includes(seg))
143
169
  .map((k) => pageGlobs[k]!);
@@ -150,7 +176,7 @@ function normalizeRawPageSlug(slug: string): string {
150
176
 
151
177
  /** The subpage slug a glob key encodes for `scope`, or null if it's not one. */
152
178
  function rawPageSlugForKey(scope: string, key: string): string | null {
153
- const seg = `/tenants/${scope}/content/pages-html/`;
179
+ const seg = `${tenantDirSegment(scope)}content/pages-html/`;
154
180
  const start = key.indexOf(seg);
155
181
  if (start < 0 || !key.endsWith(".html")) return null;
156
182
  return key.slice(start + seg.length, -".html".length);
@@ -187,7 +213,7 @@ async function readRawDevFile(scope: string, relative: string): Promise<string |
187
213
  // provider.ts lives at apps/storefront/src/lib/storyblok/ — the repo root is five
188
214
  // levels up, mirroring the import.meta.glob prefix ("../../../../../tenants/...").
189
215
  const here = dirname(fileURLToPath(import.meta.url));
190
- const abs = resolve(here, "../../../../..", "tenants", scope, "content", relative);
216
+ const abs = resolve(here, "../../../../..", ...tenantDirRelative(scope).split("/"), "content", relative);
191
217
  return await readFile(abs, "utf8");
192
218
  } catch {
193
219
  return null;
@@ -306,13 +332,13 @@ export class LocalContentProvider implements ContentProvider {
306
332
 
307
333
  async readArtifact(relative: string): Promise<string | null> {
308
334
  const normalized = relative.replace(/^\/+/, "");
309
- const marker = `/tenants/${this.scope}/content/${normalized}`;
335
+ const marker = `${tenantDirSegment(this.scope)}content/${normalized}`;
310
336
  const key = Object.keys(artifactGlobs).find((candidate) => candidate.endsWith(marker));
311
337
  return key ? artifactGlobs[key]!() : null;
312
338
  }
313
339
 
314
340
  async listArtifactPaths(prefix = ""): Promise<string[]> {
315
- const marker = `/tenants/${this.scope}/content/`;
341
+ const marker = `${tenantDirSegment(this.scope)}content/`;
316
342
  return Object.keys(artifactGlobs)
317
343
  .flatMap((key) => {
318
344
  const at = key.indexOf(marker);
@@ -20,6 +20,7 @@ export const prerender = false;
20
20
  // utilities + reference theme-token fallbacks so the (token-styled) components
21
21
  // render here without the app Layout. The `.sg-commerce` wrapper pins the
22
22
  // reference palette so they look coherent regardless of the marketing theme CSS.
23
+ import { tenantDirSegment } from "@tot/public-runtime";
23
24
  import "@/styles/global.css";
24
25
  import PriceDisplay from "@/components/commerce/PriceDisplay.astro";
25
26
  import RatingStars from "@/components/commerce/RatingStars.astro";
@@ -459,10 +460,13 @@ const sampleMarketingBlocks: MarketingBlock[] = [
459
460
  // Colocated repo-root layout: from src/pages/style-guide/[tenant]/ the repo
460
461
  // root is six levels up.
461
462
  const themeModules = import.meta.glob<Record<string, any>>(
462
- "../../../../../../tenants/*/content/themes/*.json",
463
+ [
464
+ "../../../../../../tenants/*/content/themes/*.json",
465
+ "../../../../../../tenants/e2e/*/content/themes/*.json",
466
+ ],
463
467
  { eager: true, import: "default" },
464
468
  );
465
- const seg = `/tenants/${tenant}/content/themes/`;
469
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
466
470
  const t = Object.entries(themeModules)
467
471
  .filter(([path]) => path.includes(seg))
468
472
  .map(([, mod]) => mod)
@@ -475,10 +479,13 @@ if (!t) return new Response(null, { status: 404 });
475
479
  // platform /shared/commerce-chrome.css instead so the marketing-component
476
480
  // samples below still render in-brand.
477
481
  const chromeModules = import.meta.glob<Record<string, any>>(
478
- "../../../../../../tenants/*/content/chrome.json",
482
+ [
483
+ "../../../../../../tenants/*/content/chrome.json",
484
+ "../../../../../../tenants/e2e/*/content/chrome.json",
485
+ ],
479
486
  { eager: true, import: "default" },
480
487
  );
481
- const chromeSeg = `/tenants/${tenant}/content/chrome.json`;
488
+ const chromeSeg = `${tenantDirSegment(tenant ?? "")}content/chrome.json`;
482
489
  const chromeConfig = Object.entries(chromeModules).find(([path]) => path.includes(chromeSeg))?.[1];
483
490
  // Adoption is now "does chrome.json carry the consolidated header/footer
484
491
  // shape" (lib/chrome/model.ts ChromeConfig) — the old `sharedChrome: true`
@@ -30,11 +30,16 @@ const cspNonce = Astro.locals.cspNonce;
30
30
 
31
31
  // Tenant-local theme descriptors. From src/pages/style-guide/[tenant]/chrome/ the
32
32
  // repo root is seven levels up.
33
+ import { tenantDirSegment } from "@tot/public-runtime";
34
+
33
35
  const themeModules = import.meta.glob<Record<string, any>>(
34
- "../../../../../../../tenants/*/content/themes/*.json",
36
+ [
37
+ "../../../../../../../tenants/*/content/themes/*.json",
38
+ "../../../../../../../tenants/e2e/*/content/themes/*.json",
39
+ ],
35
40
  { eager: true, import: "default" },
36
41
  );
37
- const seg = `/tenants/${tenant}/content/themes/`;
42
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
38
43
  const t = Object.entries(themeModules)
39
44
  .filter(([path]) => path.includes(seg))
40
45
  .map(([, mod]) => mod)
@@ -17,6 +17,7 @@
17
17
  */
18
18
  export const prerender = false;
19
19
 
20
+ import { tenantDirSegment } from "@tot/public-runtime";
20
21
  import "@/styles/global.css";
21
22
  import type { MarkdownInstance } from "astro";
22
23
  import StyleGuideNav from "@/components/style-guide/StyleGuideNav.astro";
@@ -27,10 +28,13 @@ const cspNonce = Astro.locals.cspNonce;
27
28
  // Tenant-local theme descriptors. From src/pages/style-guide/[tenant]/guide/ the
28
29
  // repo root is seven levels up (same depth as the chrome guide).
29
30
  const themeModules = import.meta.glob<Record<string, any>>(
30
- "../../../../../../../tenants/*/content/themes/*.json",
31
+ [
32
+ "../../../../../../../tenants/*/content/themes/*.json",
33
+ "../../../../../../../tenants/e2e/*/content/themes/*.json",
34
+ ],
31
35
  { eager: true, import: "default" },
32
36
  );
33
- const seg = `/tenants/${tenant}/content/themes/`;
37
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
34
38
  const t = Object.entries(themeModules)
35
39
  .filter(([path]) => path.includes(seg))
36
40
  .map(([, mod]) => mod)
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export const prerender = false;
18
18
 
19
+ import { tenantDirSegment } from "@tot/public-runtime";
19
20
  import StyleGuideNav from "@/components/style-guide/StyleGuideNav.astro";
20
21
 
21
22
  const { tenant } = Astro.params;
@@ -24,10 +25,13 @@ const cspNonce = Astro.locals.cspNonce;
24
25
  // Tenant-local theme descriptors. Colocated repo-root layout: from
25
26
  // src/pages/style-guide/[tenant]/ the repo root is six levels up.
26
27
  const themeModules = import.meta.glob<Record<string, any>>(
27
- "../../../../../../tenants/*/content/themes/*.json",
28
+ [
29
+ "../../../../../../tenants/*/content/themes/*.json",
30
+ "../../../../../../tenants/e2e/*/content/themes/*.json",
31
+ ],
28
32
  { eager: true, import: "default" },
29
33
  );
30
- const seg = `/tenants/${tenant}/content/themes/`;
34
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
31
35
  const themes = Object.entries(themeModules)
32
36
  .filter(([path]) => path.includes(seg))
33
37
  .map(([, mod]) => mod);
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * Preview/dev-only STYLE-GUIDE ROOT INDEX — `/_style-guide/`.
4
4
  *
5
- * Lists EVERY tenant and each of its theme guides, so all clients' guides are
6
- * discoverable from one place. Review surface only: noindex, never in the
5
+ * Lists every REAL tenant and each of its theme guides, so all clients' guides
6
+ * are discoverable from one place. Platform test tenants (the `.e2e.test` class,
7
+ * colocated under tenants/e2e/) are NOT listed — a generalized listing shows
8
+ * clients, not fixtures. `?includeTestTenants=1` opts them back in for someone
9
+ * who came here to look at one. Review surface only: noindex, never in the
7
10
  * sitemap, never part of production marketing publishing. Tenant-agnostic —
8
11
  * theme data is tenant-local (tenants/<tenant>/content/themes/*.json).
9
12
  *
@@ -17,25 +20,33 @@
17
20
  */
18
21
  export const prerender = false;
19
22
 
23
+ import { listedTenantIds } from "@tot/public-runtime";
24
+
20
25
  const cspNonce = Astro.locals.cspNonce;
26
+ const includeTestTenants = Astro.url.searchParams.get("includeTestTenants") === "1";
21
27
 
22
28
  // All tenant-local theme descriptors, grouped by tenant. Colocated repo-root
23
29
  // layout: from src/pages/style-guide/ the repo root is five levels up.
24
30
  const themeModules = import.meta.glob<Record<string, any>>(
25
- "../../../../../tenants/*/content/themes/*.json",
31
+ [
32
+ "../../../../../tenants/*/content/themes/*.json",
33
+ "../../../../../tenants/e2e/*/content/themes/*.json",
34
+ ],
26
35
  { eager: true, import: "default" },
27
36
  );
28
37
 
29
38
  const ROLE_ORDER: Record<string, number> = { current: 0, candidate: 1, legacy: 2 };
30
39
  const byTenant = new Map<string, any[]>();
31
40
  for (const [path, mod] of Object.entries(themeModules)) {
32
- const m = /\/tenants\/([^/]+)\/content\/themes\//.exec(path);
41
+ const m = /\/tenants\/(?:e2e\/)?([^/]+)\/content\/themes\//.exec(path);
33
42
  const tenant = m?.[1];
34
43
  if (!tenant) continue;
35
44
  if (!byTenant.has(tenant)) byTenant.set(tenant, []);
36
45
  byTenant.get(tenant)!.push(mod);
37
46
  }
47
+ const listedIds = new Set(listedTenantIds([...byTenant.keys()], { includeTestTenants }));
38
48
  const tenants = [...byTenant.entries()]
49
+ .filter(([tenant]) => listedIds.has(tenant))
39
50
  .map(([tenant, themes]) => ({
40
51
  tenant,
41
52
  themes: themes.sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9)),
@@ -77,7 +88,10 @@ const tenants = [...byTenant.entries()]
77
88
  <div class="wrap">
78
89
  <span class="flag">Preview · dev only · noindex</span>
79
90
  <h1>Client style guides</h1>
80
- <p class="sub">Every tenant and its theme guides. Open a guide to preview the standard components and page sections re-skinned under that theme. Nothing here re-skins a live site.</p>
91
+ <p class="sub">Every client tenant and its theme guides. Open a guide to preview the standard components and page sections re-skinned under that theme. Nothing here re-skins a live site.</p>
92
+ <p class="sub">{includeTestTenants
93
+ ? <span>Showing platform test tenants too — <a href="/_style-guide/">hide them</a>.</span>
94
+ : <span>Platform test tenants are hidden — <a href="/_style-guide/?includeTestTenants=1">show them</a>.</span>}</p>
81
95
 
82
96
  {tenants.map(({ tenant, themes }) => (
83
97
  <section class="tenant">
@@ -13,10 +13,19 @@
13
13
  * Depth: from src/themes/tenants/ the repo root is five levels up
14
14
  * (tenants→themes→src→storefront→apps→root).
15
15
  */
16
- import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
16
+ import {
17
+ listedTenantIds,
18
+ tenantIdFromPath,
19
+ type DeepPartial,
20
+ type TenantListingOptions,
21
+ type ThemeTokens,
22
+ } from "@tot/public-runtime";
17
23
 
24
+ // Both tiers: real tenants at `tenants/<id>/`, platform test tenants one level
25
+ // deeper at `tenants/e2e/<id>/`. Vite needs literal patterns, so the nesting is
26
+ // spelled out; `tenantIdFromPath` recovers the id from either shape.
18
27
  const modules = import.meta.glob<Record<string, unknown>>(
19
- "../../../../../tenants/*/theme.json",
28
+ ["../../../../../tenants/*/theme.json", "../../../../../tenants/e2e/*/theme.json"],
20
29
  {
21
30
  eager: true,
22
31
  import: "default",
@@ -34,8 +43,7 @@ function stripMeta(obj: Record<string, unknown>): DeepPartial<ThemeTokens> {
34
43
 
35
44
  const THEMES: Record<string, DeepPartial<ThemeTokens>> = {};
36
45
  for (const [path, mod] of Object.entries(modules)) {
37
- // Path looks like ".../tenants/<id>/theme.json" the id is the dir segment.
38
- const id = /\/tenants\/([^/]+)\/theme\.json$/.exec(path)?.[1];
46
+ const id = tenantIdFromPath(path);
39
47
  if (!id) continue;
40
48
  THEMES[id] = stripMeta(mod as Record<string, unknown>);
41
49
  }
@@ -45,7 +53,11 @@ export function getTenantTheme(tenantId: string): DeepPartial<ThemeTokens> {
45
53
  return THEMES[tenantId] ?? {};
46
54
  }
47
55
 
48
- /** Tenant ids that ship a theme file (for diagnostics / listing). */
49
- export function tenantsWithThemes(): string[] {
50
- return Object.keys(THEMES);
56
+ /**
57
+ * Tenant ids that ship a theme file (for diagnostics / listing). Platform test
58
+ * tenants are excluded by default — pass `{ includeTestTenants: true }` to see
59
+ * the whole set.
60
+ */
61
+ export function tenantsWithThemes(opts: TenantListingOptions = {}): string[] {
62
+ return listedTenantIds(Object.keys(THEMES), opts);
51
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -546,6 +546,97 @@ export function isInternalTestTenant(
546
546
  return t.classification === "internal-test" || t.appDomain.endsWith(".e2e.test");
547
547
  }
548
548
 
549
+ // ---------------------------------------------------------------------------
550
+ // Where a tenant's colocated directory lives.
551
+ //
552
+ // A tenant IS a directory. Real tenants sit directly under `tenants/<id>/`.
553
+ // PLATFORM TEST tenants — every id ending `.e2e.test`, the single greppable
554
+ // class — sit one level deeper, under `tenants/e2e/<id>/`, so `ls tenants/`
555
+ // shows only real tenants and a generalized listing can drop the whole test
556
+ // class with one predicate instead of a hand-maintained denylist.
557
+ //
558
+ // The id → directory mapping is TOTAL and mechanical: nothing else decides it,
559
+ // so a glob, an fs walk, and a KV artifact path can never disagree about where
560
+ // a tenant lives.
561
+ // ---------------------------------------------------------------------------
562
+
563
+ /** The directory segment platform test tenants are nested under. */
564
+ export const E2E_TENANT_DIR = "e2e";
565
+
566
+ /**
567
+ * True for a PLATFORM TEST tenant id — the `<scenario>.e2e.test` class. This is
568
+ * the id-only form of {@link isInternalTestTenant} (which also honours an
569
+ * explicit `classification`), for the many call sites that hold an id string
570
+ * and no config: globs, fs walks, CLI arguments, listing filters.
571
+ */
572
+ export function isE2ETenantId(tenantId: string): boolean {
573
+ return /\.e2e\.test$/.test(tenantId);
574
+ }
575
+
576
+ /**
577
+ * The repo-relative directory for a tenant id: `tenants/e2e/<id>` for a test
578
+ * tenant, `tenants/<id>` for a real one. Always POSIX-separated — callers that
579
+ * need an OS path join its segments themselves.
580
+ */
581
+ export function tenantDirRelative(tenantId: string): string {
582
+ return isE2ETenantId(tenantId)
583
+ ? `tenants/${E2E_TENANT_DIR}/${tenantId}`
584
+ : `tenants/${tenantId}`;
585
+ }
586
+
587
+ /**
588
+ * The `/tenants/…/<id>/` substring that identifies one tenant inside an
589
+ * absolute path or a Vite glob key — leading AND trailing slash, so a bare
590
+ * suffix match can never mistake `a.e2e.test` for `extra-a.e2e.test`.
591
+ */
592
+ export function tenantDirSegment(tenantId: string): string {
593
+ return `/${tenantDirRelative(tenantId)}/`;
594
+ }
595
+
596
+ /**
597
+ * The tenant id a `…/tenants/[e2e/]<id>/…` path encodes, or null when the path
598
+ * names no tenant. The inverse of {@link tenantDirRelative}: the optional `e2e/`
599
+ * segment is consumed, so a nested test tenant yields its OWN id and never the
600
+ * literal `e2e`.
601
+ */
602
+ export function tenantIdFromPath(path: string): string | null {
603
+ return /\/tenants\/(?:e2e\/)?([^/]+)\//.exec(path)?.[1] ?? null;
604
+ }
605
+
606
+ /** Opt back IN to platform test tenants on a generalized listing. */
607
+ export interface TenantListingOptions {
608
+ /**
609
+ * Include platform test tenants. Default `false`: a GENERALIZED listing —
610
+ * one that enumerates "all tenants" for a human to look at or a bulk job to
611
+ * walk — shows only real tenants. A caller that genuinely wants the test
612
+ * class asks for it here, explicitly, at the call site.
613
+ */
614
+ includeTestTenants?: boolean;
615
+ }
616
+
617
+ /**
618
+ * Narrow a set of tenant ids to what a generalized listing should show.
619
+ * Platform test tenants are dropped unless explicitly requested.
620
+ */
621
+ export function listedTenantIds(
622
+ ids: readonly string[],
623
+ opts: TenantListingOptions = {},
624
+ ): string[] {
625
+ return opts.includeTestTenants ? [...ids] : ids.filter((id) => !isE2ETenantId(id));
626
+ }
627
+
628
+ /**
629
+ * The listing counterpart for full configs — drops anything
630
+ * {@link isInternalTestTenant} recognizes (the `.e2e.test` class AND an
631
+ * explicitly marked clone), unless explicitly requested.
632
+ */
633
+ export function listedTenants<T extends Pick<TenantConfig, "classification" | "appDomain">>(
634
+ tenants: readonly T[],
635
+ opts: TenantListingOptions = {},
636
+ ): T[] {
637
+ return opts.includeTestTenants ? [...tenants] : tenants.filter((t) => !isInternalTestTenant(t));
638
+ }
639
+
549
640
  // ---------------------------------------------------------------------------
550
641
  // Non-premium (self-serve) tenant identity.
551
642
  //
@@ -31,10 +31,11 @@
31
31
  * reference files that don't exist. The Worker-plane build never sets this, so
32
32
  * R2 shadowing semantics are unchanged there.
33
33
  */
34
- import { cp, mkdir, readdir, rm } from "node:fs/promises";
35
- import { existsSync } from "node:fs";
34
+ import { cp, mkdir, rm } from "node:fs/promises";
35
+ import { existsSync, readdirSync, statSync } from "node:fs";
36
36
  import { join, resolve, dirname } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
+ import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
38
39
 
39
40
  const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
40
41
  const src = resolve(repoRoot, "tenants");
@@ -53,15 +54,21 @@ const versionedEnv = new Set(
53
54
  .filter(Boolean),
54
55
  );
55
56
  const bakeVersioned = process.env.TENANT_ASSETS_BAKE_VERSIONED === "1";
57
+ const tenantDir = (id) => join(repoRoot, ...tenantDirSegments(id));
56
58
  const isVersioned = (id) =>
57
59
  !bakeVersioned &&
58
60
  (versionedEnv.has(id) ||
59
- existsSync(join(src, id, ".tot", "assets-versioned")));
61
+ existsSync(join(tenantDir(id), ".tot", "assets-versioned")));
62
+
63
+ // Both tiers: real tenants directly under tenants/, platform test tenants one
64
+ // level deeper under tenants/e2e/. The destination stays FLAT — the served URL
65
+ // is `/tenants/<id>/…` for every tenant, whichever tier its source sits in.
66
+ const ids = listTenantDirIds(src, readdirSync, (p) => statSync(p).isDirectory(), join);
60
67
 
61
68
  let copied = 0;
62
69
  let skipped = 0;
63
- for (const id of await readdir(src)) {
64
- const pub = join(src, id, "public");
70
+ for (const id of ids) {
71
+ const pub = join(tenantDir(id), "public");
65
72
  if (!existsSync(pub)) continue;
66
73
  // Versioned tenants are served from R2 — baking would shadow the R2 route.
67
74
  if (isVersioned(id)) {
@@ -24,12 +24,13 @@ import { join, dirname } from "node:path";
24
24
  import { createMcpClient } from "../../packages/cli/src/mcp.mjs";
25
25
  import { resolveDeveloperSession, AuthUnavailableError } from "../../packages/cli/src/auth.mjs";
26
26
  import { applyUnifiedDiff } from "./unified-diff.mjs";
27
+ import { tenantDirSegments } from "../lib/tenant-dirs.mjs";
27
28
 
28
29
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
29
30
 
30
31
  /** Where a tenant's editable files + AI-edit history live (monorepo layout). */
31
32
  export function tenantPaths(repoRoot, tenantId) {
32
- const dir = join(repoRoot, "tenants", tenantId);
33
+ const dir = join(repoRoot, ...tenantDirSegments(tenantId));
33
34
  return {
34
35
  dir,
35
36
  home: join(dir, "content", "home.html"),
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { existsSync } from "node:fs";
21
21
  import { resolve } from "node:path";
22
+ import { tenantDirSegments } from "../lib/tenant-dirs.mjs";
22
23
 
23
24
  /**
24
25
  * @returns {{ok:true, tenantId:string, sourceRoot:string, gitCwd:string}
@@ -72,5 +73,5 @@ export function resolveTenantSourceRoot(env = process.env) {
72
73
  export function resolveDevSourceRoot(workspacePath, repoRoot, tenant) {
73
74
  return workspacePath
74
75
  ? resolve(workspacePath)
75
- : resolve(repoRoot, "tenants", tenant);
76
+ : resolve(repoRoot, ...tenantDirSegments(tenant));
76
77
  }
@@ -62,6 +62,7 @@ import {
62
62
  import { dirname, resolve, join } from "node:path";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { resolveDevSourceRoot } from "./dev/tenant-source-root.mjs";
65
+ import { tenantDirRelative, tenantDirSegments } from "./lib/tenant-dirs.mjs";
65
66
  import { firstFreePort } from "./dev/port-check.mjs";
66
67
  import { resync, startWatch } from "./dev/checkout-watch.mjs";
67
68
 
@@ -244,7 +245,7 @@ if (workspacePath) {
244
245
  // structurally (ignoring any legacy `repo` field in the checkout's .tot/config.json).
245
246
  const wsRel = String(map.workspace ?? "").replace(/\/+$/, "");
246
247
  if (!wsRel) continue;
247
- const repoRel = join("tenants", tenant, wsRel);
248
+ const repoRel = join(tenantDirRelative(tenant), wsRel);
248
249
 
249
250
  const source = resolve(wsRoot, wsRel); // real files in the checkout
250
251
  const target = resolve(repoRoot, repoRel); // where the app globs them
@@ -288,7 +289,7 @@ if (workspacePath) {
288
289
  cleanupGrafts();
289
290
  process.exit(1);
290
291
  }
291
- } else if (!existsSync(resolve(repoRoot, "tenants", tenant, "content"))) {
292
+ } else if (!existsSync(resolve(repoRoot, ...tenantDirSegments(tenant), "content"))) {
292
293
  console.warn(`⚠ no tenants/${tenant}/content/ found — the tenant may not be registered yet.`);
293
294
  }
294
295