@tokenoftrust/cli 1.3.4-rc.4 → 1.3.4

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.
Files changed (37) hide show
  1. package/bin/tot.mjs +8 -0
  2. package/package.json +2 -1
  3. package/src/app-scaffold.mjs +84 -0
  4. package/src/banner.mjs +46 -0
  5. package/src/commands/app/dev.mjs +268 -0
  6. package/src/commands/app/index.mjs +35 -0
  7. package/src/commands/app/scaffold.mjs +57 -0
  8. package/src/commands/dev.mjs +57 -13
  9. package/src/commands/start.mjs +76 -17
  10. package/src/commands/validate.mjs +4 -0
  11. package/src/dev-logs.mjs +34 -7
  12. package/src/validate.mjs +77 -8
  13. package/src/vendor/private-apps-devkit.mjs +490 -0
  14. package/template/private-app/.env.example +10 -0
  15. package/template/private-app/Dockerfile +12 -0
  16. package/template/private-app/README.md +45 -0
  17. package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
  18. package/template/private-app/server.js +81 -0
  19. package/template/private-app/tot-app.json +29 -0
  20. package/template/sample-store/content/chrome.html +140 -0
  21. package/template/sample-store/content/chrome.json +86 -0
  22. package/template/sample-store/content/home.html +121 -0
  23. package/template/sample-store/content/home.json +50 -0
  24. package/template/sample-store/content/pages/about.json +10 -0
  25. package/template/sample-store/content/pages/privacy.json +10 -0
  26. package/template/sample-store/content/pages/shipping-returns.json +10 -0
  27. package/template/sample-store/content/pages-html/blogs/news.html +26 -0
  28. package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
  29. package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
  30. package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
  31. package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
  32. package/template/sample-store/public/logo.svg +6 -0
  33. package/template/sample-store/public/pages/home.css +120 -0
  34. package/template/sample-store/public/pages/mkt.css +185 -0
  35. package/template/sample-store/public/pages/page.css +155 -0
  36. package/template/sample-store/public/themes/sample.css +76 -0
  37. package/template/sample-store/theme.json +38 -0
@@ -67,6 +67,7 @@ import {
67
67
  import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
68
68
  import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
69
69
  import { streamDevLogs } from "../dev-logs.mjs";
70
+ import { milestoneBanner, cockpitUrlFrom, DEVELOPER_COCKPIT } from "../banner.mjs";
70
71
  import { IDEAS } from "./ideas.mjs";
71
72
 
72
73
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -196,15 +197,36 @@ export async function run(argv, ctx) {
196
197
  await preflight(ctx, env);
197
198
  console.log(` ✓ signed in as ${describeIdentity(session)} via ${mcpOrigin(baseUrl)}`);
198
199
 
200
+ // Resolve the activity-bridge credential + the hosted Developer Cockpit URL
201
+ // ONCE, up front — both the pre-pick "keep the cockpit alive" heartbeat below
202
+ // (b2) and the post-boot heartbeat + milestone banners (u2) use them.
203
+ const bridgeEnv = activityBridgeEnv(env);
204
+ const cockpitUrl = cockpitUrlFrom(bridgeEnv.TOT_DEV_ACTIVITY_URL);
205
+
206
+ // b2 — a >1-store identity blocks on the interactive store picker below, and
207
+ // (before this) the hosted cockpit got NO heartbeat until the dev server
208
+ // spawned much later, so a multi-store dev's cockpit sat on Step 1 forever
209
+ // while the terminal quietly waited for a number. Start beating NOW (no local
210
+ // URL yet — the store isn't checked out) so the cockpit keeps receiving
211
+ // liveness during the pick + checkout + boot; the post-boot heartbeat (with
212
+ // the live localhost URL) takes over once we have it.
213
+ const stopEarlyHeartbeat = startHeartbeatFromEnv(bridgeEnv, {});
214
+
199
215
  // 3. store — auto-pick, use --tenant, use the remembered one, or choose (A4).
200
216
  // Keep the raw client_list so a tool ERROR (unauthenticated / not-entitled) is
201
217
  // surfaced with who + where, not collapsed to an empty list (fb-...f45zg9).
202
- const listResp = await client.callTool("client_list", {});
203
- const stores = normalizeStores(listResp);
204
- const tenant = await resolveTenant(stores, args, env, baseUrl, {
205
- session,
206
- listErr: storeListError(listResp),
207
- });
218
+ let tenant;
219
+ try {
220
+ const listResp = await client.callTool("client_list", {});
221
+ const stores = normalizeStores(listResp);
222
+ tenant = await resolveTenant(stores, args, env, baseUrl, {
223
+ session,
224
+ listErr: storeListError(listResp),
225
+ });
226
+ } catch (e) {
227
+ stopEarlyHeartbeat();
228
+ throw e;
229
+ }
208
230
 
209
231
  // 4. checkout → ./<tenant> (reuse an existing checkout on a re-run),
210
232
  // OVERLAPPED (C1) with prefetching the runner: the native artifact by
@@ -225,11 +247,15 @@ export async function run(argv, ctx) {
225
247
  prefetchRuntime(client, devArgs, env, runtime, ctx),
226
248
  ]);
227
249
 
250
+ // u3 — say EXACTLY where the code landed + THE one next step, so a freshly
251
+ // checked-out dev is never left wondering what to open. Absolute path (works
252
+ // from any directory) and a single, unambiguous action.
253
+ printCheckoutLanding(dir, cockpitUrl);
254
+
228
255
  // 5. dev — native by default (F3), Docker on --docker or the fallback
229
256
  // above; wait for the server, open the browser (C/D).
230
257
  const ctxDev = detectContext(dir);
231
258
  let url, handle;
232
- const bridgeEnv = activityBridgeEnv(env);
233
259
  if (runtime.useDocker) {
234
260
  const plan = buildContainerPlan(dir, devArgs, ctxDev);
235
261
  url = plan.url;
@@ -244,7 +270,10 @@ export async function run(argv, ctx) {
244
270
  // Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
245
271
  // URL for the life of the run — CLI-side, using the cached bridge credential
246
272
  // (no-op when none is cached). The runner keeps reporting file-saves itself.
247
- const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url });
273
+ // Hand off from the pre-pick (URL-less) beat to this URL-bearing one so the
274
+ // cockpit's live local link lights up exactly when the server is up (b2).
275
+ stopEarlyHeartbeat();
276
+ const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url, cwd: dir });
248
277
  handle.done.finally(() => stopHeartbeat());
249
278
 
250
279
  // The runner's stdio is "piped" (its logs are held until the aha), so this
@@ -264,11 +293,11 @@ export async function run(argv, ctx) {
264
293
  // (A3) so the "instant" claim is measured. The "Connect Claude" step is
265
294
  // intentionally removed for now — a blocking prompt here meant Ctrl-C'ing it
266
295
  // tore down the dev server; revisit AI-connect as a non-blocking step later.
267
- printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt));
296
+ printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt), cockpitUrl);
268
297
 
269
298
  // 7. hand the terminal to the running dev server until Ctrl-C.
270
299
  console.log("\n Watching your store — edit content/home.html + save. Ctrl-C to stop.\n");
271
- streamDevLogs(handle.child);
300
+ streamDevLogs(handle.child, { cockpitUrl });
272
301
  return handle.done;
273
302
  } catch (e) {
274
303
  console.error(formatError(e));
@@ -547,8 +576,14 @@ async function resolveTenant(stores, args, env, baseUrl, { session = null, listE
547
576
  next: `tot start --tenant <tenant> (one of: ${pick.stores.map((s) => s.id).join(", ")})`,
548
577
  });
549
578
  }
550
- console.log(" Which store do you want to work on?\n");
579
+ // Loud + unmissable: a >1-store dev is often looking at their browser
580
+ // cockpit (which is waiting on this pick), so make the terminal shout for
581
+ // their attention rather than sitting as a quiet one-liner (b2).
582
+ console.log("");
583
+ console.log(" ⚑ ACTION NEEDED IN YOUR TERMINAL — you can build on several stores.");
584
+ console.log(" Pick one here to light up your cockpit:\n");
551
585
  pick.stores.forEach((s, i) => console.log(` ${i + 1}. ${s.id}${s.name ? ` — ${s.name}` : ""}`));
586
+ console.log("");
552
587
  const idx = await promptChoice(pick.stores.length);
553
588
  console.log("");
554
589
  tenant = pick.stores[idx].id;
@@ -611,13 +646,37 @@ export function formatElapsed(ms) {
611
646
  return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
612
647
  }
613
648
 
614
- /** The crafted "you're live" ending — leads with the AI-wow (G), seeded with
615
- * IDEAS[0] (G2/G3 one prompt list shared with `tot ideas`, no drift). */
616
- function printLiveEnding(tenant, url, elapsed) {
649
+ /**
650
+ * u3after checkout, tell the developer EXACTLY where their code landed and
651
+ * THE single next step. `dir` is absolute (from resolve(cwd, tenant)) so the
652
+ * "open this" path works no matter what directory they're in. When we know the
653
+ * Developer Cockpit URL, the one next step is framed as "edit a line → see it in
654
+ * your cockpit"; otherwise it's the same edit-to-see-it-reload action.
655
+ */
656
+ function printCheckoutLanding(dir, cockpitUrl = null) {
617
657
  console.log("");
618
- console.log(` You're live.${elapsed ? ` (${elapsed})` : ""}`);
619
- console.log(` ${url}`);
620
- console.log(" Edit content/home.html + save it reloads.");
658
+ console.log(` 📁 Your store code is at: ${dir}`);
659
+ const where = cockpitUrl ? `your ${DEVELOPER_COCKPIT}` : "your browser";
660
+ console.log(` Next: open it in your editor and change one line in`);
661
+ console.log(` content/home.html — you'll see it reflected in ${where}.`);
662
+ }
663
+
664
+ /** The crafted "you're live" ending — a PROMINENT milestone banner (u2) that,
665
+ * when we hold a Developer Cockpit URL, explicitly sends the developer BACK to
666
+ * their cockpit as the next place to look; then the AI-wow (G), seeded with
667
+ * IDEAS[0] (G2/G3 — one prompt list shared with `tot ideas`, no drift). */
668
+ function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
669
+ const lines = [
670
+ `✨ You're live.${elapsed ? ` (${elapsed})` : ""}`,
671
+ ` ${url}`,
672
+ " Edit content/home.html + save → it reloads.",
673
+ ];
674
+ if (cockpitUrl) {
675
+ lines.push("");
676
+ lines.push(`➜ Back to your ${DEVELOPER_COCKPIT} to watch it happen:`);
677
+ lines.push(` ${cockpitUrl}`);
678
+ }
679
+ console.log(milestoneBanner(lines));
621
680
  console.log(" " + versionStamp("native"));
622
681
  console.log("");
623
682
  console.log(" Now try, in Claude:");
@@ -79,6 +79,10 @@ export function run(argv, ctx) {
79
79
  const { ok, findings } = validateTenant(target.dir, {
80
80
  tenantId: target.tenantId ?? undefined,
81
81
  scope: target.scope ?? undefined,
82
+ // A checkout / bare-tenant target is served by the ToT storefront platform, so
83
+ // commerce tenants own the framework routes (/collections, /account, …). Only an
84
+ // arbitrary --workspace <dir> stays conservative (unless its config resolves the host).
85
+ mode: args.workspace ? "workspace" : "monorepo",
82
86
  });
83
87
 
84
88
  if (args.json) {
package/src/dev-logs.mjs CHANGED
@@ -4,9 +4,10 @@
4
4
  * (dev.mjs). Extracted so the two entrypoints stream identically instead of one
5
5
  * inheriting the raw vite/astro firehose while the other collapses it.
6
6
  *
7
- * Dependency-free (no imports) pure line buffering + filtering over a child's
8
- * stdout/stderr.
7
+ * Depends only on the shared, dependency-free banner helper otherwise pure
8
+ * line buffering + filtering over a child's stdout/stderr.
9
9
  */
10
+ import { milestoneBanner, DEVELOPER_COCKPIT } from "./banner.mjs";
10
11
 
11
12
  /**
12
13
  * Stream the running dev server's output in BUSINESS terms. The runner + Vite +
@@ -16,17 +17,32 @@
16
17
  * save-reload into one clean "↻ your store reloaded", drop the known internal
17
18
  * noise, and pass anything else through (indented) so nothing important is
18
19
  * hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
20
+ *
21
+ * The FIRST save→reload is the aha milestone (u2): with a `cockpitUrl` it's a
22
+ * PROMINENT banner that sends the developer back to their Developer Cockpit to
23
+ * see the change; every reload after that is the quiet "↻ your store reloaded"
24
+ * line so a working dev loop doesn't get spammed with banners.
19
25
  * @param {import("node:child_process").ChildProcess} child
26
+ * @param {{ cockpitUrl?: string|null, out?: (s: string) => void, reloadDebounceMs?: number }} [opts]
27
+ * `out` is the sink (default: process.stdout.write) — injectable for tests.
28
+ * `reloadDebounceMs` collapses a single save's HMR burst into one line (default 1000ms).
20
29
  */
21
- export function streamDevLogs(child) {
30
+ export function streamDevLogs(
31
+ child,
32
+ { cockpitUrl = null, out = (s) => process.stdout.write(s), reloadDebounceMs = 1000 } = {},
33
+ ) {
22
34
  // Startup churn + tool internals — never user-facing. Matched AFTER stripping
23
35
  // the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
24
36
  // timestamped internal line like "10:50:17 [vite] connected" is still dropped.
37
+ // The node:* / ExperimentalWarning / (node:NNNN) / "--trace-warnings" and the
38
+ // boot "fatal: not a git repository" lines are dropped too (b4) — scary,
39
+ // non-actionable boot noise that reads as a broken first run.
25
40
  const NOISE =
26
- /^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d)/i;
41
+ /^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d|\(node:\d+\)|ExperimentalWarning|node:internal\/|\(Use `node --trace-warnings|fatal: not a git repository)/i;
27
42
  // A real save-triggered reload (not startup "program reload" churn).
28
43
  const RELOAD = /(hmr update|page reload)/i;
29
44
  let reloadPending = null;
45
+ let sawFirstReload = false;
30
46
  const emit = (line) => {
31
47
  const t = line.replace(/\s+$/, "");
32
48
  if (!t) return;
@@ -35,13 +51,24 @@ export function streamDevLogs(child) {
35
51
  const body = t.replace(/^\d{1,2}:\d{2}:\d{2}(\.\d+)?\s+/, "").replace(/^\s+/, "");
36
52
  if (RELOAD.test(body)) {
37
53
  if (reloadPending) return; // debounce a burst into one line
38
- reloadPending = setTimeout(() => { reloadPending = null; }, 1000);
54
+ reloadPending = setTimeout(() => { reloadPending = null; }, reloadDebounceMs);
39
55
  if (reloadPending.unref) reloadPending.unref();
40
- process.stdout.write(" ↻ your store reloaded\n");
56
+ if (!sawFirstReload && cockpitUrl) {
57
+ sawFirstReload = true;
58
+ out(milestoneBanner([
59
+ "↻ Your edit is live — your store reloaded.",
60
+ "",
61
+ `➜ See it in your ${DEVELOPER_COCKPIT}:`,
62
+ ` ${cockpitUrl}`,
63
+ ]) + "\n");
64
+ return;
65
+ }
66
+ sawFirstReload = true;
67
+ out(" ↻ your store reloaded\n");
41
68
  return;
42
69
  }
43
70
  if (NOISE.test(body)) return;
44
- process.stdout.write(` ${t}\n`);
71
+ out(` ${t}\n`);
45
72
  };
46
73
  lineStream(child.stdout, emit);
47
74
  lineStream(child.stderr, emit);
package/src/validate.mjs CHANGED
@@ -7,8 +7,10 @@
7
7
  * mirror the reconcile pipeline's own gate (@tot/private-controlplane
8
8
  * customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Kept in step
9
9
  * with the in-repo source of truth `scripts/tenant/validate.mjs` (which the CI
10
- * regression test imports). FOLLOW-UP: collapse the two onto this module as the
11
- * single source once the package is the install surface.
10
+ * regression test imports) including its platform-route awareness (commerce
11
+ * tenants own /collections, /products/<handle>, /search, /saved, /account, /cart,
12
+ * so links into those are not false `link-dangling` warnings). FOLLOW-UP: collapse
13
+ * the two onto this module as the single source once the package is the install surface.
12
14
  *
13
15
  * Pure + dependency-free (readFileSync only). `validateTenant(dir)` is the entry
14
16
  * point; `validateConfigShape` is exported for focused testing.
@@ -333,10 +335,72 @@ const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
333
335
  const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
334
336
  const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
335
337
 
338
+ // --- platform-owned routes (framework surfaces, NOT tenant pages) ------------
339
+ /**
340
+ * When the tenant is served BY this ToT storefront platform, the platform itself
341
+ * owns a set of framework routes — catalog (`/collections`, `/collections/<handle>`,
342
+ * `/products/<handle>`), `/search`, `/saved`, the auth/account route (`/account`),
343
+ * and `/cart` when cart/checkout is on. A COMMERCE tenant that links into those is
344
+ * linking to a live platform surface, so such links must NOT be flagged
345
+ * `link-dangling` — only genuinely-missing tenant pages/assets should warn.
346
+ *
347
+ * MARKETING tenants get none of these (their commerce affordances are off), so they
348
+ * stay strict. Standalone / unresolved hosts also stay conservative — they can't
349
+ * assume the eventual host is this platform — UNLESS the config resolves the host
350
+ * (`"hostPlatform": "tot-storefront"`).
351
+ *
352
+ * The route→feature mapping mirrors `resolveTenantFeatures` in
353
+ * `@tot/public-runtime` tenant.ts (catalog / productSearch / savedItems) — keep in step.
354
+ */
355
+ const COMMERCE_ROUTE_FEATURES = { catalog: true, productSearch: true, savedItems: true };
356
+
357
+ /**
358
+ * @param {any} config parsed `.tot/config.json` (or null)
359
+ * @param {boolean} hostIsPlatform is the serving host this ToT storefront platform?
360
+ * @returns {{active:boolean, owns:(path:string)=>boolean}}
361
+ */
362
+ function resolvePlatformRouteOwnership(config, hostIsPlatform) {
363
+ const inert = { active: false, owns: () => false };
364
+ if (!hostIsPlatform) return inert; // standalone / unresolved host → conservative
365
+ const siteType = config?.siteType ?? "commerce"; // unset == commerce (platform default)
366
+ if (siteType === "marketing") return inert; // marketing owns no commerce routes
367
+ // commerce feature defaults, with any explicit per-flag overrides layered on
368
+ const features = { ...COMMERCE_ROUTE_FEATURES };
369
+ const overrides = config?.features;
370
+ if (overrides && typeof overrides === "object") {
371
+ for (const [k, v] of Object.entries(overrides)) if (v !== undefined && k in features) features[k] = v;
372
+ }
373
+ const cartEnabled = config?.capabilities?.cartCheckout?.enabled ?? true; // commerce default: on
374
+
375
+ const exact = new Set(["/account"]); // the auth/account route the platform currently serves
376
+ const prefixes = ["/account/"];
377
+ if (features.catalog) {
378
+ exact.add("/collections");
379
+ prefixes.push("/collections/", "/products/");
380
+ }
381
+ if (features.productSearch) exact.add("/search");
382
+ if (features.savedItems) exact.add("/saved");
383
+ if (cartEnabled) {
384
+ exact.add("/cart");
385
+ prefixes.push("/cart/");
386
+ }
387
+ return {
388
+ active: true,
389
+ owns(path) {
390
+ const norm = path.replace(/\/+$/, "") || "/";
391
+ if (exact.has(norm)) return true;
392
+ return prefixes.some((p) => path.startsWith(p));
393
+ },
394
+ };
395
+ }
396
+
336
397
  /**
337
398
  * Full static validation of a tenant directory (content/ public/ theme.json [.tot/]).
338
399
  * @param {string} tenantDir absolute path to the tenant dir
339
- * @param {{tenantId?:string, scope?:string}} [opts]
400
+ * @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace"}} [opts]
401
+ * `mode` — "monorepo" (default): served by this platform, so commerce tenants own
402
+ * the framework routes. "workspace": a standalone checkout, conservative about
403
+ * platform-route ownership unless the config resolves the host (`hostPlatform`).
340
404
  * @returns {{ok:boolean, findings:Finding[]}}
341
405
  */
342
406
  export function validateTenant(tenantDir, opts = {}) {
@@ -370,6 +434,10 @@ export function validateTenant(tenantDir, opts = {}) {
370
434
  );
371
435
  }
372
436
  const scope = opts.scope || config?.scope;
437
+ // platform-route awareness: which framework routes the host platform owns for this tenant
438
+ const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
439
+ const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
440
+ const platformRoutes = resolvePlatformRouteOwnership(config, hostIsPlatform);
373
441
  if (config?.capabilities) {
374
442
  findings.push(...validateCapabilitiesDoc(config.capabilities, ".tot/config.json capabilities", config));
375
443
  }
@@ -456,7 +524,7 @@ export function validateTenant(tenantDir, opts = {}) {
456
524
  }
457
525
 
458
526
  for (const m of html.matchAll(HREF_RE)) {
459
- findings.push(...checkLink(m[1].trim(), r, scope, pageTargets));
527
+ findings.push(...checkLink(m[1].trim(), r, scope, pageTargets, platformRoutes.owns));
460
528
  }
461
529
  for (const m of html.matchAll(SRC_RE)) {
462
530
  const f = checkAsset(m[1].trim(), r, publicDir, opts.tenantId || config?.tenant);
@@ -480,7 +548,7 @@ function buildPageTargetSet(contentDir, pagesDir) {
480
548
  return set;
481
549
  }
482
550
 
483
- function checkLink(href, file, scope, pageTargets) {
551
+ function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = () => false) {
484
552
  const out = [];
485
553
  if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
486
554
  if (/^https?:\/\/localhost(?::\d+)?/i.test(href)) {
@@ -501,9 +569,10 @@ function checkLink(href, file, scope, pageTargets) {
501
569
  if (href.startsWith("/tenants/")) return out;
502
570
  const path = href.split(/[?#]/)[0];
503
571
  const norm = path.replace(/\/+$/, "") || "/";
504
- if (!pageTargets.has(path) && !pageTargets.has(norm) && !pageTargets.has(norm + "/")) {
505
- out.push(mk(WARN, "link-dangling", file, `internal link ${path} does not resolve to a known page (would 404)`));
506
- }
572
+ if (pageTargets.has(path) || pageTargets.has(norm) || pageTargets.has(norm + "/")) return out;
573
+ // a framework route the host platform owns (e.g. /collections, /account) is not a dangling tenant page
574
+ if (ownsPlatformRoute(path)) return out;
575
+ out.push(mk(WARN, "link-dangling", file, `internal link ${path} does not resolve to a known page (would 404)`));
507
576
  return out;
508
577
  }
509
578
  out.push(mk(WARN, "link-relative", file, `relative link "${href}" — internal links should be root-absolute (/foo/)`));