@tokenoftrust/storefront-runner 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +26 -3
- package/apps/storefront/dev-plugins/tenant-validate-on-save.mjs +78 -0
- package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +1 -1
- package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +1 -1
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +9 -4
- package/apps/storefront/src/config/resolver.ts +14 -6
- package/apps/storefront/src/lib/blog/collection.ts +17 -7
- package/apps/storefront/src/lib/blog/provider.ts +19 -6
- package/apps/storefront/src/lib/storyblok/provider.ts +40 -14
- package/apps/storefront/src/middleware/index.ts +17 -1
- package/apps/storefront/src/pages/admin.astro +16 -14
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +11 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +6 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +6 -2
- package/apps/storefront/src/pages/style-guide/index.astro +19 -5
- package/apps/storefront/src/themes/tenants/index.ts +19 -7
- package/package.json +1 -1
- package/packages/public-runtime/src/tenant.ts +91 -0
- package/scripts/build/copy-tenant-assets.mjs +12 -5
- package/scripts/dev/ai-edit.mjs +2 -1
- package/scripts/dev/tenant-source-root.mjs +2 -1
- package/scripts/tot-dev.mjs +3 -2
|
@@ -4,15 +4,23 @@ 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, tenantDirRelative } from "../../../scripts/lib/tenant-dirs.mjs";
|
|
8
|
+
import { validateSavedFile } from "./tenant-validate-on-save.mjs";
|
|
9
|
+
import { validateTenant } from "../../../scripts/tenant/validate.mjs";
|
|
7
10
|
import { readFile } from "node:fs/promises";
|
|
8
11
|
|
|
9
12
|
// Repo-root tenants/ dir (colocated tenant layout) + the generated static dest
|
|
10
13
|
// the predev copy step mirrors public/ into. This module lives at
|
|
11
14
|
// apps/storefront/dev-plugins/, so tenants/ is three levels up. Forward-slash
|
|
12
15
|
// normalized to match Vite's ctx.file (Vite always reports posix-style paths).
|
|
13
|
-
const
|
|
16
|
+
const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
|
17
|
+
const TENANTS_DIR = join(REPO_ROOT, "tenants").replace(/\\/g, "/");
|
|
14
18
|
const PUBLIC_TENANTS_DEST = fileURLToPath(new URL("../public/tenants", import.meta.url));
|
|
15
19
|
|
|
20
|
+
/** Per-file finding signatures, so an unchanged warning is not reprinted on every
|
|
21
|
+
* keystroke-save. Lives for the dev server's lifetime. */
|
|
22
|
+
const savedFileFindings = new Map();
|
|
23
|
+
|
|
16
24
|
/**
|
|
17
25
|
* Load a TS module's exports for Node-side (plugin) use, reusing Vite's own
|
|
18
26
|
* transform pipeline instead of a plain `import` (which can't parse `.ts`) or
|
|
@@ -53,7 +61,10 @@ async function loadTsModule(server, url) {
|
|
|
53
61
|
export function classifyTenantFile(file) {
|
|
54
62
|
const f = String(file).replace(/\\/g, "/");
|
|
55
63
|
if (!f.startsWith(TENANTS_DIR + "/")) return null;
|
|
56
|
-
|
|
64
|
+
// "<id>/<...>" for a real tenant, "e2e/<id>/<...>" for a platform test tenant —
|
|
65
|
+
// consume the nesting segment so `id` is always the tenant's own id.
|
|
66
|
+
let rel = f.slice(TENANTS_DIR.length + 1);
|
|
67
|
+
if (rel.startsWith(E2E_TENANT_DIR + "/")) rel = rel.slice(E2E_TENANT_DIR.length + 1);
|
|
57
68
|
const slash = rel.indexOf("/");
|
|
58
69
|
if (slash < 0) return null;
|
|
59
70
|
const rest = rel.slice(slash + 1);
|
|
@@ -97,7 +108,7 @@ export function isVersionedTenant(id) {
|
|
|
97
108
|
.map((s) => s.trim())
|
|
98
109
|
.filter(Boolean);
|
|
99
110
|
if (envList.includes(id)) return true;
|
|
100
|
-
return existsSync(join(
|
|
111
|
+
return existsSync(join(REPO_ROOT, ...tenantDirSegments(id), ".tot", "assets-versioned"));
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
/**
|
|
@@ -260,6 +271,18 @@ export function tenantHotReload() {
|
|
|
260
271
|
// matching reload is timestamped by dev-loop-monitor's ws/hot wrap.
|
|
261
272
|
markSave(rest, "change");
|
|
262
273
|
|
|
274
|
+
// Validate the save — deliberately NOT awaited. The reload branches below
|
|
275
|
+
// dispatch exactly as they did before, and the findings arrive in the
|
|
276
|
+
// terminal a beat later. Putting this in the reload path would tax every
|
|
277
|
+
// save to report something that is almost always empty.
|
|
278
|
+
void validateSavedFile({
|
|
279
|
+
tenantDir: join(REPO_ROOT, tenantDirRelative(id)),
|
|
280
|
+
tenantId: id,
|
|
281
|
+
rest,
|
|
282
|
+
memo: savedFileFindings,
|
|
283
|
+
validate: validateTenant,
|
|
284
|
+
});
|
|
285
|
+
|
|
263
286
|
// Content HTML: the change invalidates the SSR module graph, so the
|
|
264
287
|
// Cloudflare Vite plugin reloads the worker and — once it is ready — emits
|
|
265
288
|
// its own correctly-timed full-reload (which the injected Vite HMR client
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate a tenant file on save, OFF the reload path.
|
|
3
|
+
*
|
|
4
|
+
* The validator's warnings used to reach a developer only if they remembered to
|
|
5
|
+
* run `tot validate`, or at `tot submit` — both after the fact. The earliest
|
|
6
|
+
* moment is the save itself, and `tot dev` already watches every tenant file.
|
|
7
|
+
*
|
|
8
|
+
* The constraint is that the dev loop's whole value is save→HMR speed, so this
|
|
9
|
+
* must never sit in that path: `handleHotUpdate` fires this and does not await it.
|
|
10
|
+
* The reload is dispatched exactly as before and the findings land a beat later in
|
|
11
|
+
* the terminal. A whole-tenant pass measures ~13ms, so there is no need to scope
|
|
12
|
+
* the validator itself — only to keep it off the critical path.
|
|
13
|
+
*
|
|
14
|
+
* Errors are NOT reported here. An error already blocks `tot submit`, and the dev
|
|
15
|
+
* loop is where a page is half-written by definition — a fragment mid-edit
|
|
16
|
+
* legitimately fails checks it will pass a keystroke later. Warnings are the ones
|
|
17
|
+
* that otherwise ship silently, which is the gap this closes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Findings worth interrupting a save for: warnings on the file just saved. */
|
|
21
|
+
export function findingsForSavedFile(findings, rest) {
|
|
22
|
+
const target = String(rest || "").replace(/\\/g, "/");
|
|
23
|
+
return (findings || []).filter(
|
|
24
|
+
(f) => f.level !== "error" && String(f.file || "").replace(/\\/g, "/").endsWith(target),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A stable identity for a file's finding set, so an unchanged set is not reprinted. */
|
|
29
|
+
export function findingsSignature(findings) {
|
|
30
|
+
return findings.map((f) => `${f.rule}:${f.message}`).sort().join("|");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decide what to print for one save. Pure: the caller owns the memo and the
|
|
35
|
+
* console. Returns null when there is nothing new to say — an unchanged finding
|
|
36
|
+
* set on every keystroke-save is noise that trains people to ignore the channel.
|
|
37
|
+
*
|
|
38
|
+
* @param {{ findings: any[], rest: string, previousSignature: string | undefined }} input
|
|
39
|
+
* @returns {{ signature: string, lines: string[] } | null}
|
|
40
|
+
*/
|
|
41
|
+
export function reportForSave({ findings, rest, previousSignature }) {
|
|
42
|
+
const mine = findingsForSavedFile(findings, rest);
|
|
43
|
+
const signature = findingsSignature(mine);
|
|
44
|
+
if (signature === (previousSignature ?? "")) return null;
|
|
45
|
+
// Newly clean: say so once, so a fixed warning visibly clears rather than just
|
|
46
|
+
// never being mentioned again.
|
|
47
|
+
if (!mine.length) return { signature, lines: [` ✓ ${rest} — validation warnings cleared`] };
|
|
48
|
+
return {
|
|
49
|
+
signature,
|
|
50
|
+
lines: [
|
|
51
|
+
` ⚠ ${rest} — ${mine.length} validation warning(s)`,
|
|
52
|
+
...mine.map((f) => ` ⚠ [${f.rule}] ${f.message}`),
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run the validator for the tenant owning the saved file and print anything new.
|
|
59
|
+
* Never throws and never returns a rejected promise: a validator fault must not
|
|
60
|
+
* break the dev loop, which is the thing the developer is actually using.
|
|
61
|
+
*
|
|
62
|
+
* @param {{ tenantDir: string, tenantId: string, rest: string,
|
|
63
|
+
* memo: Map<string, string>, validate: (dir: string, opts?: any) => any,
|
|
64
|
+
* log?: (line: string) => void }} input
|
|
65
|
+
*/
|
|
66
|
+
export async function validateSavedFile({ tenantDir, tenantId, rest, memo, validate, log = console.error }) {
|
|
67
|
+
let findings;
|
|
68
|
+
try {
|
|
69
|
+
({ findings } = validate(tenantDir, { tenantId }));
|
|
70
|
+
} catch {
|
|
71
|
+
return; // a validator fault is never worth breaking the loop over
|
|
72
|
+
}
|
|
73
|
+
const key = `${tenantId}/${rest}`;
|
|
74
|
+
const report = reportForSave({ findings, rest, previousSignature: memo.get(key) });
|
|
75
|
+
if (!report) return;
|
|
76
|
+
memo.set(key, report.signature);
|
|
77
|
+
for (const line of report.lines) log(line);
|
|
78
|
+
}
|
|
@@ -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 = "
|
|
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
|
-
"
|
|
15
|
+
"dc9fb2e28b7d8041ca901ae2df204ab34da1eb957cd88f75437361e42491e566" as const;
|
|
16
16
|
|
|
17
17
|
export interface V84CleanCloneGraphIdentity {
|
|
18
18
|
readonly paths: readonly string[];
|
|
@@ -42,11 +42,16 @@ const { tenant, basePath } = Astro.locals;
|
|
|
42
42
|
|
|
43
43
|
// Viewer capability, mirrored from the same session model the API routes gate
|
|
44
44
|
// on (resolveOwnerSession admits capability owner|admin). UX-only: the server
|
|
45
|
-
// re-authorizes every action.
|
|
45
|
+
// re-authorizes every action. Checked against `envelope.targetTenant`, not the
|
|
46
|
+
// routed `tenant.appDomain` — a ToT-staff viewer operating a store other than
|
|
47
|
+
// the one that served this admin route must be checked against the tenant
|
|
48
|
+
// they SELECTED, else a real grant on the selected tenant reads as "none".
|
|
46
49
|
const viewerSession = await readViewerSession(Astro);
|
|
47
|
-
// Session capability counts only when the session's resource IS
|
|
50
|
+
// Session capability counts only when the session's resource IS the selected tenant.
|
|
48
51
|
const sessionCapability =
|
|
49
|
-
viewerSession
|
|
52
|
+
viewerSession && viewerSession.resource === envelope.targetTenant
|
|
53
|
+
? viewerSession.capability
|
|
54
|
+
: undefined;
|
|
50
55
|
const viewerCapability =
|
|
51
56
|
Astro.locals.viewer?.capability ?? sessionCapability ?? "none";
|
|
52
57
|
// A signed-in DEVELOPER (not owner/admin) may hold an explicit, owner-revocable
|
|
@@ -58,7 +63,7 @@ let viewerCanShip = viewerCapability === "owner" || viewerCapability === "admin"
|
|
|
58
63
|
if (!viewerCanShip && Astro.locals.viewer?.email) {
|
|
59
64
|
const shipGrant = await resolveViewerShipCapability(
|
|
60
65
|
Astro.locals.viewer.email,
|
|
61
|
-
|
|
66
|
+
envelope.targetTenant,
|
|
62
67
|
);
|
|
63
68
|
if (shipGrant === "ship-on-behalf") viewerCanShip = true;
|
|
64
69
|
}
|
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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>(
|
|
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>(
|
|
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
|
-
|
|
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>(
|
|
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>(
|
|
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>(
|
|
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>(
|
|
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>(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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, "../../../../..", "
|
|
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 =
|
|
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 =
|
|
341
|
+
const marker = `${tenantDirSegment(this.scope)}content/`;
|
|
316
342
|
return Object.keys(artifactGlobs)
|
|
317
343
|
.flatMap((key) => {
|
|
318
344
|
const at = key.indexOf(marker);
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
PUBLIC_HOME_TENANT_ID,
|
|
70
70
|
} from "@/lib/auth/loginGate";
|
|
71
71
|
import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
|
|
72
|
+
import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
|
|
72
73
|
import { gatedHoldingResponse } from "@/lib/auth/gatePage";
|
|
73
74
|
import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
|
|
74
75
|
import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
|
|
@@ -258,7 +259,22 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
258
259
|
// an explicit vendor selection sees the staffRoles-scoped capability. The
|
|
259
260
|
// ship gates (decideIsOwner/resolveShipPrincipal) read this capability,
|
|
260
261
|
// so binding it here keeps owner-resolution server-side + tenant-correct.
|
|
261
|
-
|
|
262
|
+
//
|
|
263
|
+
// On an admin path, "the host" a staff viewer is really acting on is the
|
|
264
|
+
// ?asTenant= impersonation target (mirrors admin.astro/AdminPublishTab.astro's
|
|
265
|
+
// own resolveAdminTenantMode call) — resolving capability against the routed
|
|
266
|
+
// host instead left a staff viewer who selected another tenant reading as
|
|
267
|
+
// "not an owner" of it even while holding a real grant, because their
|
|
268
|
+
// staffSelection is bound to the SELECTED tenant, never the host app.
|
|
269
|
+
const capabilityHost = isAdminPath(url.pathname)
|
|
270
|
+
? resolveAdminTenantMode({
|
|
271
|
+
session: viewerRecord,
|
|
272
|
+
adminAppTenant: tenant.appDomain,
|
|
273
|
+
requestedTarget: url.searchParams.get("asTenant"),
|
|
274
|
+
nowSeconds,
|
|
275
|
+
}).envelope.targetTenant
|
|
276
|
+
: tenant.appDomain;
|
|
277
|
+
const hostCapability = sessionHostCapability(viewerRecord, capabilityHost, nowSeconds);
|
|
262
278
|
locals.viewer = {
|
|
263
279
|
email: viewerRecord.email,
|
|
264
280
|
roles: hostCapability ? [hostCapability] : viewerRecord.roles,
|
|
@@ -54,25 +54,14 @@ const viewerEmail = (Astro.locals.viewer?.email ?? viewerSession?.email ?? "").t
|
|
|
54
54
|
const viewerDomain = viewerEmail.split("@").at(-1) ?? "";
|
|
55
55
|
const canViewInternalGuide = viewerDomain === "tokenoftrust.com";
|
|
56
56
|
|
|
57
|
-
// dg6 — admin session ENTRY resolved SERVER-SIDE against the resolved tenant's
|
|
58
|
-
// appDomain (never client input, never a bearer secret): an authenticated
|
|
59
|
-
// owner/team member for THIS tenant reaches the admin context; a non-member /
|
|
60
|
-
// anonymous visitor was already held at the sign-in gate (middleware). Consumes
|
|
61
|
-
// u10's owner + ship-capability model; the go-live tabs (AdminPublishTab) read
|
|
62
|
-
// the same host-bound `Astro.locals.viewer.capability`, so the shell just stamps
|
|
63
|
-
// the authoritative principal for chrome + tests — it does not re-gate.
|
|
64
|
-
const adminEntry = resolveAdminEntry({
|
|
65
|
-
record: viewerSession,
|
|
66
|
-
hostResource: tenant.appDomain,
|
|
67
|
-
nowSeconds: Math.floor(Date.now() / 1000),
|
|
68
|
-
});
|
|
69
|
-
|
|
70
57
|
// Internal admin-app tenant mode (baseline doc §Admin App As Tenant,
|
|
71
58
|
// tenant-envelope-contract): every mounted tab receives an explicit
|
|
72
59
|
// { adminAppTenant, targetTenant } envelope. Only a real ToT-staff session
|
|
73
60
|
// may move targetTenant away from the routed tenant, and only to a tenant
|
|
74
61
|
// they already have real access to — see lib/adminTenantEnvelope.ts for the
|
|
75
|
-
// authorization rule (deny-by-default, refused not hidden-only).
|
|
62
|
+
// authorization rule (deny-by-default, refused not hidden-only). Resolved
|
|
63
|
+
// BEFORE adminEntry below: a staff viewer's capability must be checked
|
|
64
|
+
// against the tenant they selected, not the app's routed host.
|
|
76
65
|
const adminTenantMode = resolveAdminTenantMode({
|
|
77
66
|
session: viewerSession,
|
|
78
67
|
adminAppTenant: tenant.appDomain,
|
|
@@ -80,6 +69,19 @@ const adminTenantMode = resolveAdminTenantMode({
|
|
|
80
69
|
nowSeconds: Math.floor(Date.now() / 1000),
|
|
81
70
|
});
|
|
82
71
|
const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = adminTenantMode;
|
|
72
|
+
|
|
73
|
+
// dg6 — admin session ENTRY resolved SERVER-SIDE against the SELECTED tenant's
|
|
74
|
+
// appDomain (never client input, never a bearer secret): an authenticated
|
|
75
|
+
// owner/team member for THIS tenant reaches the admin context; a non-member /
|
|
76
|
+
// anonymous visitor was already held at the sign-in gate (middleware). Consumes
|
|
77
|
+
// u10's owner + ship-capability model; the go-live tabs (AdminPublishTab) read
|
|
78
|
+
// the same tenant-bound `Astro.locals.viewer.capability`, so the shell just stamps
|
|
79
|
+
// the authoritative principal for chrome + tests — it does not re-gate.
|
|
80
|
+
const adminEntry = resolveAdminEntry({
|
|
81
|
+
record: viewerSession,
|
|
82
|
+
hostResource: tenantEnvelope.targetTenant,
|
|
83
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
84
|
+
});
|
|
83
85
|
---
|
|
84
86
|
|
|
85
87
|
<!doctype html>
|
|
@@ -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
|
-
|
|
463
|
+
[
|
|
464
|
+
"../../../../../../tenants/*/content/themes/*.json",
|
|
465
|
+
"../../../../../../tenants/e2e/*/content/themes/*.json",
|
|
466
|
+
],
|
|
463
467
|
{ eager: true, import: "default" },
|
|
464
468
|
);
|
|
465
|
-
const seg =
|
|
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
|
-
|
|
482
|
+
[
|
|
483
|
+
"../../../../../../tenants/*/content/chrome.json",
|
|
484
|
+
"../../../../../../tenants/e2e/*/content/chrome.json",
|
|
485
|
+
],
|
|
479
486
|
{ eager: true, import: "default" },
|
|
480
487
|
);
|
|
481
|
-
const chromeSeg =
|
|
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
|
-
|
|
36
|
+
[
|
|
37
|
+
"../../../../../../../tenants/*/content/themes/*.json",
|
|
38
|
+
"../../../../../../../tenants/e2e/*/content/themes/*.json",
|
|
39
|
+
],
|
|
35
40
|
{ eager: true, import: "default" },
|
|
36
41
|
);
|
|
37
|
-
const seg =
|
|
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
|
-
|
|
31
|
+
[
|
|
32
|
+
"../../../../../../../tenants/*/content/themes/*.json",
|
|
33
|
+
"../../../../../../../tenants/e2e/*/content/themes/*.json",
|
|
34
|
+
],
|
|
31
35
|
{ eager: true, import: "default" },
|
|
32
36
|
);
|
|
33
|
-
const seg =
|
|
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
|
-
|
|
28
|
+
[
|
|
29
|
+
"../../../../../../tenants/*/content/themes/*.json",
|
|
30
|
+
"../../../../../../tenants/e2e/*/content/themes/*.json",
|
|
31
|
+
],
|
|
28
32
|
{ eager: true, import: "default" },
|
|
29
33
|
);
|
|
30
|
-
const seg =
|
|
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
|
|
6
|
-
* discoverable from one place.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
3
|
+
"version": "2.0.2",
|
|
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,
|
|
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(
|
|
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
|
|
64
|
-
const pub = join(
|
|
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)) {
|
package/scripts/dev/ai-edit.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
76
|
+
: resolve(repoRoot, ...tenantDirSegments(tenant));
|
|
76
77
|
}
|
package/scripts/tot-dev.mjs
CHANGED
|
@@ -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(
|
|
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,
|
|
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
|
|