@tokenoftrust/storefront-runner 2.0.2 → 2.0.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.
- package/apps/storefront/.astro/content.d.ts +159 -0
- package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
- package/apps/storefront/.astro/types.d.ts +3 -0
- package/apps/storefront/node_modules/.bin/acorn +43 -0
- package/apps/storefront/node_modules/.bin/astro +43 -0
- package/apps/storefront/node_modules/.bin/astro-check +43 -0
- package/apps/storefront/node_modules/.bin/browserslist +43 -0
- package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
- package/apps/storefront/node_modules/.bin/depcruise +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
- package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
- package/apps/storefront/node_modules/.bin/esbuild +43 -0
- package/apps/storefront/node_modules/.bin/jiti +43 -0
- package/apps/storefront/node_modules/.bin/playwright +43 -0
- package/apps/storefront/node_modules/.bin/playwright-core +43 -0
- package/apps/storefront/node_modules/.bin/prettier +43 -0
- package/apps/storefront/node_modules/.bin/rollup +43 -0
- package/apps/storefront/node_modules/.bin/tsc +43 -0
- package/apps/storefront/node_modules/.bin/tsserver +43 -0
- package/apps/storefront/node_modules/.bin/tsx +43 -0
- package/apps/storefront/node_modules/.bin/vite +43 -0
- package/apps/storefront/node_modules/.bin/vitest +43 -0
- package/apps/storefront/node_modules/.bin/workerd +43 -0
- package/apps/storefront/node_modules/.bin/wrangler +43 -0
- package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
- package/apps/storefront/node_modules/.bin/yaml +43 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
- package/apps/storefront/src/layouts/Layout.astro +1 -1
- package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
- package/apps/storefront/src/lib/shared/capability.ts +68 -0
- package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
- package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
- package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
- package/apps/storefront/src/pages/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +1 -1
- package/package.json +1 -1
- package/packages/public-runtime/node_modules/.bin/tsc +43 -0
- package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
- package/packages/public-runtime/node_modules/.bin/vitest +43 -0
- package/pnpm-workspace.yaml +3 -0
- package/scripts/lib/tenant-dirs.mjs +80 -0
- package/scripts/tenant/lint-embeds.mjs +180 -0
- package/scripts/tenant/materialize-tenant-content.mjs +533 -0
- package/scripts/tenant/validate.mjs +837 -0
- package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +0 -68
- package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +0 -111
- package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +0 -2
- package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +0 -247
- package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +0 -73
- package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +0 -79
- package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +0 -10982
- package/apps/storefront/scripts/import-v84-through-ws2.ts +0 -38
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +0 -2
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +0 -4
- package/apps/storefront/scripts/install-v84-native-through-ws1.ts +0 -185
- package/apps/storefront/scripts/prove-clone-isolation.ts +0 -506
- package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +0 -148
- package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +0 -35
- package/apps/storefront/scripts/replay-v84-clean-clone.ts +0 -214
- package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +0 -18
- package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +0 -18
- package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +0 -18
- package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +0 -17
- package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +0 -15
- package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +0 -21
- package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +0 -42
- package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +0 -145
- package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +0 -18
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +0 -531
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +0 -878
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +0 -1435
- package/apps/storefront/src/components/admin/AdminCheckoutSettingsPanel.astro +0 -226
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +0 -593
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +0 -465
- package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +0 -105
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +0 -901
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +0 -2983
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +0 -435
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +0 -782
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +0 -611
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +0 -1223
- package/apps/storefront/src/lib/apps/adminService.ts +0 -116
- package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
- package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
- package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
- package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
- package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
- package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
- package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
- package/apps/storefront/src/lib/content-edit/client.ts +0 -286
- package/apps/storefront/src/lib/content-edit/index.ts +0 -14
- package/apps/storefront/src/lib/content-edit/route.ts +0 -25
- package/apps/storefront/src/lib/content-edit/types.ts +0 -19
- package/apps/storefront/src/lib/dev/cockpitStore.ts +0 -57
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +0 -4625
- package/apps/storefront/src/lib/publish/apex-readiness.ts +0 -360
- package/apps/storefront/src/lib/publish/buildStream.ts +0 -269
- package/apps/storefront/src/lib/publish/collapsePersistence.ts +0 -72
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +0 -267
- package/apps/storefront/src/lib/publish/domainState.ts +0 -360
- package/apps/storefront/src/lib/publish/lifecycleState.ts +0 -369
- package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +0 -29
- package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +0 -78
- package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +0 -62
- package/apps/storefront/src/lib/publish/mergeRowFindings.ts +0 -124
- package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +0 -75
- package/apps/storefront/src/lib/publish/panelPolling.ts +0 -78
- package/apps/storefront/src/lib/publish/publish-guard.ts +0 -88
- package/apps/storefront/src/lib/publish/publishFormat.ts +0 -165
- package/apps/storefront/src/lib/publish/publishHero.ts +0 -177
- package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +0 -66
- package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +0 -141
- package/apps/storefront/src/lib/publish/reviewRelease/index.ts +0 -41
- package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +0 -47
- package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +0 -42
- package/apps/storefront/src/lib/publish/reviewRelease/types.ts +0 -59
- package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +0 -440
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +0 -161
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +0 -325
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +0 -241
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +0 -439
- package/apps/storefront/src/lib/publish/sseRoute.ts +0 -119
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +0 -215
- package/apps/storefront/src/lib/publish/streamLogConsole.ts +0 -142
- package/apps/storefront/src/lib/publish/versionNumber.ts +0 -52
- package/apps/storefront/src/pages/admin/ops-timeline.astro +0 -420
- package/apps/storefront/src/pages/admin/review-release.astro +0 -265
- package/apps/storefront/src/pages/admin/select.astro +0 -96
- package/apps/storefront/src/pages/admin.astro +0 -570
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +0 -86
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +0 -174
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +0 -96
- package/apps/storefront/src/pages/api/apps/admin/health.ts +0 -44
- package/apps/storefront/src/pages/api/apps/admin/install.ts +0 -125
- package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +0 -118
- package/apps/storefront/src/pages/api/apps/admin/integrations.ts +0 -89
- package/apps/storefront/src/pages/api/apps/admin/list.ts +0 -26
- package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +0 -85
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +0 -79
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +0 -80
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +0 -98
- package/apps/storefront/src/pages/api/apps/admin/update.ts +0 -126
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +0 -70
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +0 -51
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +0 -255
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +0 -87
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +0 -162
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +0 -39
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +0 -49
- package/apps/storefront/src/pages/api/apps/v1/health.ts +0 -32
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +0 -59
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +0 -48
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +0 -73
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +0 -21
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +0 -74
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +0 -47
- package/apps/storefront/src/pages/api/cache-purge.ts +0 -55
- package/apps/storefront/src/pages/api/compliance/tamper.ts +0 -88
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +0 -164
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +0 -154
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +0 -120
- package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +0 -108
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +0 -70
- package/apps/storefront/src/pages/api/test/dev-session.ts +0 -133
- package/apps/storefront/src/pages/cockpit.astro +0 -1548
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +0 -119
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +0 -105
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +0 -177
- package/apps/storefront/src/pages/dashboard/index.astro +0 -160
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `tot validate <tenant>` — static tenant-contract linter.
|
|
4
|
+
*
|
|
5
|
+
* Catches, BEFORE a commit reaches the hosted storefront, the classes of tenant
|
|
6
|
+
* defect that otherwise fail silently or as a cryptic runtime/reconcile error:
|
|
7
|
+
* · `.tot/config.json` mapping-shape drift (the #24/#25 regression — a `kind:"dir"`
|
|
8
|
+
* / missing-`repo` mapping makes the flow-back reconcile `mapToArtifact` throw)
|
|
9
|
+
* · malformed `theme.json` / `home.json` / `chrome.json` (an eager Vite glob turns
|
|
10
|
+
* these into a WHOLE-APP BUILD FAILURE)
|
|
11
|
+
* · raw HTML that isn't a servable document, or drops CSS under the CSP nonce
|
|
12
|
+
* · non-portable internal links (`https://<self-domain>/…`, `http://localhost…`) — the
|
|
13
|
+
* class that "leaves localhost" when served locally or on a custom domain
|
|
14
|
+
* · dangling internal links / missing referenced public assets
|
|
15
|
+
*
|
|
16
|
+
* Design principle — "valid locally ≡ will reconcile in storefront": the checks below
|
|
17
|
+
* mirror the reconcile pipeline's own gate (`@tot/private-controlplane`
|
|
18
|
+
* customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Keep them in step.
|
|
19
|
+
*
|
|
20
|
+
* Dependency-free. Two modes (mirrors scripts/tot-dev.mjs):
|
|
21
|
+
* node scripts/tenant/validate.mjs <tenant> # monorepo: tenants/<tenant>/
|
|
22
|
+
* node scripts/tenant/validate.mjs --workspace <checkout> # a standalone tenant checkout
|
|
23
|
+
*
|
|
24
|
+
* Exit 0 = no errors (warnings allowed); 1 = one or more errors; 2 = usage error.
|
|
25
|
+
* `--json` prints machine-readable findings. Importable: `validateTenant`,
|
|
26
|
+
* `validateConfigShape`, `discoverTenantIds` are exported for the CI regression test.
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
29
|
+
import { dirname, resolve, join, relative } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { KNOWN_EMBEDS, lintTenantEmbeds } from "./lint-embeds.mjs";
|
|
32
|
+
import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
|
|
33
|
+
|
|
34
|
+
/** Catalogued embed slugs a tenant may opt into (mirror of EMBED_PROVIDERS). */
|
|
35
|
+
const KNOWN_EMBED_SLUGS = new Set(KNOWN_EMBEDS.map((p) => p.slug));
|
|
36
|
+
|
|
37
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
export const REPO_ROOT = resolve(here, "../..");
|
|
39
|
+
const TENANTS_DIR = join(REPO_ROOT, "tenants");
|
|
40
|
+
const tenantDir = (id) => join(REPO_ROOT, ...tenantDirSegments(id));
|
|
41
|
+
const NUL = String.fromCharCode(0);
|
|
42
|
+
|
|
43
|
+
// --- findings model ----------------------------------------------------------
|
|
44
|
+
const ERROR = "error";
|
|
45
|
+
const WARN = "warn";
|
|
46
|
+
/** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
|
|
47
|
+
|
|
48
|
+
function mk(level, rule, file, message, fix) {
|
|
49
|
+
return { level, rule, file, message, fix };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- git conflict markers ----------------------------------------------------
|
|
53
|
+
// A half-resolved merge/rebase can commit literal conflict markers into content.
|
|
54
|
+
// These are the default and diff3 marker lines, anchored
|
|
55
|
+
// at line start and exactly 7 chars with a trailing boundary — precise enough that
|
|
56
|
+
// real content never matches. `=======` / `|||||||` ALONE are NOT flagged (a lone
|
|
57
|
+
// `=======` is a common markdown/prose horizontal rule); only the START (`<<<<<<<`)
|
|
58
|
+
// and END (`>>>>>>>`) markers trigger — either one is a near-certain conflict, so
|
|
59
|
+
// we err false-negative-averse and flag on either.
|
|
60
|
+
const CONFLICT_START = /^<{7}(?=[ \t]|$)/;
|
|
61
|
+
const CONFLICT_END = /^>{7}(?=[ \t]|$)/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Line numbers (1-based) of git conflict markers in `content`. Empty ⇒ none.
|
|
65
|
+
* Pure — exported for focused testing.
|
|
66
|
+
* @param {string} content
|
|
67
|
+
* @returns {number[]}
|
|
68
|
+
*/
|
|
69
|
+
export function detectConflictMarkers(content) {
|
|
70
|
+
if (typeof content !== "string" || (!content.includes("<<<<<<<") && !content.includes(">>>>>>>"))) return [];
|
|
71
|
+
const lines = content.split(/\r?\n/);
|
|
72
|
+
const hits = [];
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
if (CONFLICT_START.test(lines[i]) || CONFLICT_END.test(lines[i])) hits.push(i + 1);
|
|
75
|
+
}
|
|
76
|
+
return hits;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Text artifacts a conflict marker can hide in (images/fonts live in public/, not scanned).
|
|
80
|
+
const CONFLICT_SCAN_EXT = new Set([".html", ".htm", ".json", ".md", ".txt", ".css", ".js", ".mjs", ".svg", ".xml"]);
|
|
81
|
+
const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])[0].toLowerCase());
|
|
82
|
+
|
|
83
|
+
// --- canonical `.tot/config.json` shape (the #24/#25 regression guard) --------
|
|
84
|
+
const KNOWN_KINDS = new Set(["file", "tree"]);
|
|
85
|
+
const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
|
|
89
|
+
* (`mapToArtifact`) requires. Pure — no I/O — so the CI regression test can call it.
|
|
90
|
+
* @param {any} config parsed JSON
|
|
91
|
+
* @param {string} file path for messages
|
|
92
|
+
* @returns {Finding[]}
|
|
93
|
+
*/
|
|
94
|
+
export function validateConfigShape(config, file = ".tot/config.json") {
|
|
95
|
+
const out = [];
|
|
96
|
+
if (config == null || typeof config !== "object") {
|
|
97
|
+
return [mk(ERROR, "config-parse", file, "config is not a JSON object")];
|
|
98
|
+
}
|
|
99
|
+
if (typeof config.tenant !== "string" || !config.tenant) {
|
|
100
|
+
out.push(mk(ERROR, "config-tenant", file, "`tenant` must be a non-empty string"));
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(config.mappings)) {
|
|
103
|
+
out.push(mk(ERROR, "config-mappings", file, "`mappings` must be an array", "seed shape: [{workspace,repo,kind}]"));
|
|
104
|
+
return out; // nothing more to check without the array
|
|
105
|
+
}
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (const [i, m] of config.mappings.entries()) {
|
|
108
|
+
const at = `${file} mappings[${i}]`;
|
|
109
|
+
if (m == null || typeof m !== "object") {
|
|
110
|
+
out.push(mk(ERROR, "mapping-shape", at, "mapping must be an object"));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (typeof m.workspace !== "string" || !m.workspace) {
|
|
114
|
+
out.push(mk(ERROR, "mapping-workspace", at, "`workspace` must be a non-empty string"));
|
|
115
|
+
} else {
|
|
116
|
+
seen.add(m.workspace.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
|
|
117
|
+
seen.add(m.workspace);
|
|
118
|
+
}
|
|
119
|
+
// THE regression guard: reconcile's mapToArtifact dereferences m.repo and only
|
|
120
|
+
// knows "file" vs tree — a missing repo or kind:"dir" makes it throw at runtime.
|
|
121
|
+
if (typeof m.repo !== "string" || !m.repo) {
|
|
122
|
+
out.push(
|
|
123
|
+
mk(ERROR, "mapping-repo", at,
|
|
124
|
+
`mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile mapToArtifact throws on undefined.repo)`,
|
|
125
|
+
`add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!KNOWN_KINDS.has(m.kind)) {
|
|
129
|
+
out.push(
|
|
130
|
+
mk(ERROR, "mapping-kind", at,
|
|
131
|
+
`\`kind\` is "${m.kind}" — must be "file" or "tree" (legacy "dir" is the #24/#25 bug)`,
|
|
132
|
+
m.kind === "dir" ? 'change "dir" → "tree"' : 'set "file" for a single file, "tree" for a directory'),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// must cover content/ + public/ + theme.json so nothing a dev edits is silently unmapped
|
|
137
|
+
for (const req of REQUIRED_WORKSPACES) {
|
|
138
|
+
const bare = req.replace(/\/+$/, "");
|
|
139
|
+
if (!seen.has(req) && !seen.has(bare)) {
|
|
140
|
+
out.push(mk(WARN, "config-coverage", file, `mappings do not cover "${req}" — edits there won't flow back`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- tiny HTML checks (mirror customization-reconcile.ts validateRawHtml) ------
|
|
147
|
+
function isFullDocument(html) {
|
|
148
|
+
const head = html.slice(0, 512).toLowerCase();
|
|
149
|
+
return head.includes("<!doctype") || head.includes("<html");
|
|
150
|
+
}
|
|
151
|
+
function validateRawHtmlBody(html) {
|
|
152
|
+
if (typeof html !== "string" || html.length === 0) return "empty document";
|
|
153
|
+
if (html.includes(NUL)) return "contains NUL byte (not text/HTML)";
|
|
154
|
+
if (!html.includes("<")) return "no markup tags found";
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
// Compact structural mirror of @tot/public-runtime chrome.ts's `ChromeConfig`
|
|
158
|
+
// shape — the consolidated header/footer schema (retired both the old lean
|
|
159
|
+
// `SiteChrome` and the `sharedChrome: true`-gated `TenantChrome`). This is
|
|
160
|
+
// NOT a full validateChromeConfig() pass — deep per-item errors are the
|
|
161
|
+
// write-path's job (packages/private-controlplane/src/customization-chrome.ts,
|
|
162
|
+
// which does call the real validateChromeConfig()); this only decides whether
|
|
163
|
+
// the tenant's chrome.json looks enough like a ChromeConfig to opt the tenant
|
|
164
|
+
// into shared chrome for the html-fragment check below. This script runs as
|
|
165
|
+
// plain Node (no TS loader), so it can't import the TS-source
|
|
166
|
+
// `@tot/public-runtime` package at runtime — this is a copy. Parity test:
|
|
167
|
+
// packages/public-runtime/tests/chrome-config-shape-parity.test.ts. Kept in
|
|
168
|
+
// step with packages/cli/src/validate.mjs's identical copy.
|
|
169
|
+
const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
|
|
170
|
+
const CHROME_FOOTER_VARIANTS = new Set(["default"]);
|
|
171
|
+
export function looksLikeChromeConfig(value) {
|
|
172
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
173
|
+
const header = value.header;
|
|
174
|
+
if (header == null || typeof header !== "object" || Array.isArray(header)) return false;
|
|
175
|
+
if (!CHROME_HEADER_VARIANTS.has(header.variant)) return false;
|
|
176
|
+
const brand = header.brand;
|
|
177
|
+
if (brand == null || typeof brand !== "object" || typeof brand.label !== "string" || brand.label.trim() === "") return false;
|
|
178
|
+
if (!Array.isArray(header.nav)) return false;
|
|
179
|
+
const footer = value.footer;
|
|
180
|
+
if (footer == null || typeof footer !== "object" || Array.isArray(footer)) return false;
|
|
181
|
+
if (!CHROME_FOOTER_VARIANTS.has(footer.variant)) return false;
|
|
182
|
+
if (!Array.isArray(footer.columns)) return false;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Does the tenant provide chrome to wrap body-only fragments? True when it
|
|
188
|
+
* ships EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json`
|
|
189
|
+
* that looks like a consolidated `ChromeConfig` (see {@link looksLikeChromeConfig}).
|
|
190
|
+
* Mirrors the serve-time resolution in apps/storefront getChromeHtml() →
|
|
191
|
+
* parseChromeConfig(): a config that parses is rendered into the wrapper
|
|
192
|
+
* (renderRawChrome), so a fragment is NOT served naked.
|
|
193
|
+
*/
|
|
194
|
+
function providesChrome(contentDir) {
|
|
195
|
+
if (existsSync(join(contentDir, "chrome.html"))) return true;
|
|
196
|
+
const configPath = join(contentDir, "chrome.json");
|
|
197
|
+
if (!existsSync(configPath)) return false;
|
|
198
|
+
const { value } = readJsonSafe(configPath);
|
|
199
|
+
return looksLikeChromeConfig(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const CAPABILITY_KEYS = new Set(["cartCheckout", "ageVerification", "exciseTax"]);
|
|
203
|
+
|
|
204
|
+
function complianceObligations(config) {
|
|
205
|
+
const compliance = config?.compliance || {};
|
|
206
|
+
return {
|
|
207
|
+
ageVerification: !!compliance.minAge,
|
|
208
|
+
exciseTax: !!compliance.nicotineWarning,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function rawHtmlAllowed(config) {
|
|
213
|
+
const obligations = complianceObligations(config);
|
|
214
|
+
const regulated = obligations.ageVerification || obligations.exciseTax;
|
|
215
|
+
return !regulated;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateCapabilitiesDoc(doc, file, config) {
|
|
219
|
+
const out = [];
|
|
220
|
+
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
221
|
+
return [mk(ERROR, "capabilities-root", file, "capabilities must be a JSON object")];
|
|
222
|
+
}
|
|
223
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
224
|
+
if (!CAPABILITY_KEYS.has(key)) {
|
|
225
|
+
out.push(mk(ERROR, "capability-unknown", file, `unknown capability "${key}"`));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (value == null || typeof value !== "object" || typeof value.enabled !== "boolean") {
|
|
229
|
+
out.push(mk(ERROR, "capability-shape", file, `${key} must be an object with boolean "enabled"`));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const obligations = complianceObligations(config);
|
|
233
|
+
if (obligations.ageVerification && doc.ageVerification?.enabled === false) {
|
|
234
|
+
out.push(mk(ERROR, "capability-compliance-floor", file, "ageVerification cannot be disabled for a tenant with compliance.minAge"));
|
|
235
|
+
}
|
|
236
|
+
if (obligations.exciseTax && doc.exciseTax?.enabled === false) {
|
|
237
|
+
out.push(mk(ERROR, "capability-compliance-floor", file, "exciseTax cannot be disabled for a tenant with compliance.nicotineWarning"));
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function validateEmbedsDoc(doc, file) {
|
|
243
|
+
// embeds.json is the tenant's self-serve third-party embed opt-in: a JSON
|
|
244
|
+
// array of catalogued provider slugs (e.g. ["pipedrive"]). An unknown slug is
|
|
245
|
+
// a WARNING, not an error — resolveEffectiveEmbeds drops it (fail-safe: the
|
|
246
|
+
// embed stays CSP-blocked), so a typo is inert rather than dangerous, but the
|
|
247
|
+
// tenant still needs to know it won't take effect.
|
|
248
|
+
if (!Array.isArray(doc)) {
|
|
249
|
+
return [mk(ERROR, "embeds-root", file, "embeds.json must be a JSON array of provider slugs, e.g. [\"pipedrive\"]")];
|
|
250
|
+
}
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const slug of doc) {
|
|
253
|
+
if (typeof slug !== "string") {
|
|
254
|
+
out.push(mk(ERROR, "embeds-shape", file, `every entry must be a string slug; got ${typeof slug}`));
|
|
255
|
+
} else if (!KNOWN_EMBED_SLUGS.has(slug)) {
|
|
256
|
+
out.push(mk(WARN, "embed-unknown", file, `"${slug}" is not a known embed provider — it will be ignored (the CSP will still block that embed). Known: ${[...KNOWN_EMBED_SLUGS].join(", ")}.`));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateScriptsDoc(doc, file, config) {
|
|
263
|
+
const out = [];
|
|
264
|
+
if (doc == null || typeof doc !== "object" || !Array.isArray(doc.scripts)) {
|
|
265
|
+
return [mk(ERROR, "scripts-root", file, 'scripts.json must have a "scripts" array')];
|
|
266
|
+
}
|
|
267
|
+
const regulated = !rawHtmlAllowed(config);
|
|
268
|
+
for (const [i, entry] of doc.scripts.entries()) {
|
|
269
|
+
const at = `${file} scripts[${i}]`;
|
|
270
|
+
if (entry == null || typeof entry !== "object") {
|
|
271
|
+
out.push(mk(ERROR, "script-entry", at, "script entry must be an object"));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (entry.isolation === "inline") {
|
|
275
|
+
out.push(
|
|
276
|
+
regulated
|
|
277
|
+
? mk(ERROR, "script-inline-regulated", at, 'inline scripts are not allowed for regulated tenants; use isolation:"sandbox"')
|
|
278
|
+
: mk(WARN, "script-inline", at, 'inline scripts run same-origin; prefer isolation:"sandbox"'),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const SUPPORTED_BLOCK_CONTRACTS = new Set(["block-palette@1", "block-palette@2", "block-palette@3"]);
|
|
286
|
+
const KNOWN_BLOCKS = new Set([
|
|
287
|
+
"hero",
|
|
288
|
+
"promo_tiles",
|
|
289
|
+
"featured_collections",
|
|
290
|
+
"featured_products",
|
|
291
|
+
"membership_band",
|
|
292
|
+
"editorial",
|
|
293
|
+
"newsletter",
|
|
294
|
+
"marketing_hero",
|
|
295
|
+
"trust_bar",
|
|
296
|
+
"split_compare",
|
|
297
|
+
"steps",
|
|
298
|
+
"card_grid",
|
|
299
|
+
"integrations",
|
|
300
|
+
"proof_strip",
|
|
301
|
+
"testimonials",
|
|
302
|
+
"faq",
|
|
303
|
+
"cta_band",
|
|
304
|
+
]);
|
|
305
|
+
const REQUIRED_BLOCK_PROPS = {
|
|
306
|
+
hero: ["headline"],
|
|
307
|
+
promo_tiles: ["tiles"],
|
|
308
|
+
featured_collections: ["handles"],
|
|
309
|
+
membership_band: ["tiers"],
|
|
310
|
+
editorial: ["title"],
|
|
311
|
+
newsletter: ["title"],
|
|
312
|
+
marketing_hero: ["headline"],
|
|
313
|
+
trust_bar: ["items"],
|
|
314
|
+
split_compare: ["title", "before", "after"],
|
|
315
|
+
steps: ["title", "steps"],
|
|
316
|
+
card_grid: ["title", "cards"],
|
|
317
|
+
integrations: ["title", "platforms"],
|
|
318
|
+
proof_strip: ["title"],
|
|
319
|
+
testimonials: ["quotes"],
|
|
320
|
+
faq: ["title", "items"],
|
|
321
|
+
cta_band: ["title"],
|
|
322
|
+
};
|
|
323
|
+
const PRODUCT_SOURCE_KEYS = new Set([
|
|
324
|
+
"collection",
|
|
325
|
+
"tag",
|
|
326
|
+
"featured",
|
|
327
|
+
"newest",
|
|
328
|
+
"bestSelling",
|
|
329
|
+
"onSale",
|
|
330
|
+
"handles",
|
|
331
|
+
"related",
|
|
332
|
+
"recentlyViewed",
|
|
333
|
+
]);
|
|
334
|
+
const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
|
|
335
|
+
|
|
336
|
+
function validateProductSource(value, file, at) {
|
|
337
|
+
const out = [];
|
|
338
|
+
if (typeof value === "string") {
|
|
339
|
+
if (!PRODUCT_SOURCE_SHORTCUTS.has(value)) {
|
|
340
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source must be one of featured, newest, bestSelling, onSale or a source object`));
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
345
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source must be a source string or object`));
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined);
|
|
349
|
+
const recognized = keys.filter((k) => PRODUCT_SOURCE_KEYS.has(k));
|
|
350
|
+
if (keys.length !== 1 || recognized.length !== 1) {
|
|
351
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source must declare exactly one recognized source key`));
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
const key = recognized[0];
|
|
355
|
+
const got = value[key];
|
|
356
|
+
if (key === "handles") {
|
|
357
|
+
if (!Array.isArray(got) || !got.every((v) => typeof v === "string")) {
|
|
358
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source.handles must be a string array`));
|
|
359
|
+
}
|
|
360
|
+
} else if (key === "collection" || key === "tag" || key === "related") {
|
|
361
|
+
if (typeof got !== "string") {
|
|
362
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be a string`));
|
|
363
|
+
}
|
|
364
|
+
} else if (got !== true) {
|
|
365
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be true`));
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function validateHomeDoc(doc, file) {
|
|
371
|
+
const out = [];
|
|
372
|
+
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
373
|
+
return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
|
|
374
|
+
}
|
|
375
|
+
if (doc.contract !== undefined && (typeof doc.contract !== "string" || !SUPPORTED_BLOCK_CONTRACTS.has(doc.contract))) {
|
|
376
|
+
out.push(mk(ERROR, "home-contract", file, `unsupported block contract "${String(doc.contract)}"`));
|
|
377
|
+
}
|
|
378
|
+
if (!Array.isArray(doc.blocks)) {
|
|
379
|
+
out.push(mk(ERROR, "home-blocks", file, "`blocks` must be an array"));
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
doc.blocks.forEach((block, i) => {
|
|
383
|
+
const at = `blocks[${i}]`;
|
|
384
|
+
if (block == null || typeof block !== "object" || Array.isArray(block)) {
|
|
385
|
+
out.push(mk(ERROR, "block-shape", file, `${at} must be an object`));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const component = block.component;
|
|
389
|
+
if (typeof component !== "string") {
|
|
390
|
+
out.push(mk(ERROR, "block-component", file, `${at}.component is required`));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!KNOWN_BLOCKS.has(component)) {
|
|
394
|
+
out.push(mk(ERROR, "block-unknown", file, `${at}.component "${component}" is not in the supported block palette`));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
|
|
398
|
+
if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
|
|
399
|
+
}
|
|
400
|
+
if (component === "featured_products") {
|
|
401
|
+
if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
|
|
402
|
+
if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
403
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
|
|
404
|
+
}
|
|
405
|
+
if (block.limit !== undefined && typeof block.limit !== "number") {
|
|
406
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- filesystem helpers ------------------------------------------------------
|
|
414
|
+
function readJsonSafe(path) {
|
|
415
|
+
try {
|
|
416
|
+
return { value: JSON.parse(readFileSync(path, "utf8")), error: null };
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return { value: null, error: /** @type {Error} */ (e).message };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function walk(dir, pred) {
|
|
422
|
+
const out = [];
|
|
423
|
+
if (!existsSync(dir)) return out;
|
|
424
|
+
for (const name of readdirSync(dir)) {
|
|
425
|
+
const p = join(dir, name);
|
|
426
|
+
const st = statSync(p);
|
|
427
|
+
if (st.isDirectory()) out.push(...walk(p, pred));
|
|
428
|
+
else if (pred(p)) out.push(p);
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Every tenant id with a directory on disk — real tenants under `tenants/`, and
|
|
435
|
+
* platform test tenants under `tenants/e2e/`.
|
|
436
|
+
*
|
|
437
|
+
* DELIBERATELY UNFILTERED, unlike the generalized listings: this drives the
|
|
438
|
+
* `.tot/config.json` shape guard, and a test tenant reconciles through the very
|
|
439
|
+
* same pipeline a real one does. Excluding the fixtures here would silently stop
|
|
440
|
+
* validating exactly the configs the e2e suites depend on.
|
|
441
|
+
*/
|
|
442
|
+
export function discoverTenantIds() {
|
|
443
|
+
if (!existsSync(TENANTS_DIR)) return [];
|
|
444
|
+
return listTenantDirIds(TENANTS_DIR, readdirSync, (p) => statSync(p).isDirectory(), join);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// --- link / asset extraction -------------------------------------------------
|
|
448
|
+
const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
|
|
449
|
+
const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
|
|
450
|
+
const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
|
|
451
|
+
/** An in-page skip link: an anchor to a #main-ish target, or one carrying a skip class. */
|
|
452
|
+
const SKIP_LINK_RE = /<a\b[^>]*(?:class="[^"]*\bskip[-\w]*\b[^"]*"|href="#(?:main|content|main-content)\b")/i;
|
|
453
|
+
|
|
454
|
+
// --- platform-owned routes (framework surfaces, NOT tenant pages) ------------
|
|
455
|
+
/**
|
|
456
|
+
* In MONOREPO mode the tenant is served BY this storefront platform, which itself
|
|
457
|
+
* owns a set of framework routes — catalog (`/collections`, `/collections/<handle>`,
|
|
458
|
+
* `/products/<handle>`), `/search`, `/saved`, the auth/account route (`/account`),
|
|
459
|
+
* and `/cart` when cart/checkout is on. A COMMERCE tenant that links into those is
|
|
460
|
+
* linking to a live platform surface, so such links must NOT be flagged
|
|
461
|
+
* `link-dangling` — only genuinely-missing tenant pages/assets should warn.
|
|
462
|
+
*
|
|
463
|
+
* MARKETING tenants get none of these (their commerce affordances are off), so they
|
|
464
|
+
* stay strict. A standalone tenant-checkout (`--workspace`) also stays conservative —
|
|
465
|
+
* it can't assume the eventual host is this platform — UNLESS the config resolves the
|
|
466
|
+
* host (`"hostPlatform": "tot-storefront"`).
|
|
467
|
+
*
|
|
468
|
+
* The route→feature mapping mirrors `resolveTenantFeatures` in
|
|
469
|
+
* `@tot/public-runtime` tenant.ts (catalog / productSearch / savedItems) — keep in step.
|
|
470
|
+
*/
|
|
471
|
+
const COMMERCE_ROUTE_FEATURES = { catalog: true, productSearch: true, savedItems: true };
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @param {any} config parsed `.tot/config.json` (or null)
|
|
475
|
+
* @param {boolean} hostIsPlatform is the serving host this ToT storefront platform?
|
|
476
|
+
* @returns {{active:boolean, owns:(path:string)=>boolean}}
|
|
477
|
+
*/
|
|
478
|
+
function resolvePlatformRouteOwnership(config, hostIsPlatform) {
|
|
479
|
+
const inert = { active: false, owns: () => false };
|
|
480
|
+
if (!hostIsPlatform) return inert; // standalone / unresolved host → conservative
|
|
481
|
+
const siteType = config?.siteType ?? "commerce"; // unset == commerce (platform default)
|
|
482
|
+
if (siteType === "marketing") return inert; // marketing owns no commerce routes
|
|
483
|
+
// commerce feature defaults, with any explicit per-flag overrides layered on
|
|
484
|
+
const features = { ...COMMERCE_ROUTE_FEATURES };
|
|
485
|
+
const overrides = config?.features;
|
|
486
|
+
if (overrides && typeof overrides === "object") {
|
|
487
|
+
for (const [k, v] of Object.entries(overrides)) if (v !== undefined && k in features) features[k] = v;
|
|
488
|
+
}
|
|
489
|
+
const cartEnabled = config?.capabilities?.cartCheckout?.enabled ?? true; // commerce default: on
|
|
490
|
+
|
|
491
|
+
const exact = new Set(["/account"]); // the auth/account route the platform currently serves
|
|
492
|
+
const prefixes = ["/account/"];
|
|
493
|
+
if (features.catalog) {
|
|
494
|
+
exact.add("/collections");
|
|
495
|
+
prefixes.push("/collections/", "/products/");
|
|
496
|
+
}
|
|
497
|
+
if (features.productSearch) exact.add("/search");
|
|
498
|
+
if (features.savedItems) exact.add("/saved");
|
|
499
|
+
if (cartEnabled) {
|
|
500
|
+
exact.add("/cart");
|
|
501
|
+
prefixes.push("/cart/");
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
active: true,
|
|
505
|
+
owns(path) {
|
|
506
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
507
|
+
if (exact.has(norm)) return true;
|
|
508
|
+
return prefixes.some((p) => path.startsWith(p));
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Full static validation of a tenant directory.
|
|
515
|
+
* @param {string} tenantDir absolute path to the tenant dir (content/ public/ theme.json [.tot/])
|
|
516
|
+
* @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace"}} [opts]
|
|
517
|
+
* `mode` — "monorepo" (default): served by this platform, so commerce tenants own
|
|
518
|
+
* the framework routes. "workspace": a standalone checkout, conservative about
|
|
519
|
+
* platform-route ownership unless the config resolves the host.
|
|
520
|
+
* @returns {{ok:boolean, findings:Finding[]}}
|
|
521
|
+
*/
|
|
522
|
+
export function validateTenant(tenantDir, opts = {}) {
|
|
523
|
+
/** @type {Finding[]} */
|
|
524
|
+
const findings = [];
|
|
525
|
+
const rel = (p) => relative(tenantDir, p) || p;
|
|
526
|
+
const contentDir = join(tenantDir, "content");
|
|
527
|
+
const publicDir = join(tenantDir, "public");
|
|
528
|
+
const pagesDir = join(contentDir, "pages-html");
|
|
529
|
+
|
|
530
|
+
// 0. basic layout
|
|
531
|
+
if (!existsSync(contentDir)) {
|
|
532
|
+
findings.push(mk(ERROR, "layout", "content/", "no content/ directory"));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 1. .tot/config.json — shape (flow-back readiness)
|
|
536
|
+
const configPath = join(tenantDir, ".tot", "config.json");
|
|
537
|
+
let config = null;
|
|
538
|
+
if (existsSync(configPath)) {
|
|
539
|
+
const { value, error } = readJsonSafe(configPath);
|
|
540
|
+
if (error) {
|
|
541
|
+
findings.push(mk(ERROR, "config-parse", ".tot/config.json", `invalid JSON: ${error}`));
|
|
542
|
+
} else {
|
|
543
|
+
config = value;
|
|
544
|
+
findings.push(...validateConfigShape(value, ".tot/config.json"));
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
findings.push(
|
|
548
|
+
mk(WARN, "config-absent", ".tot/config.json",
|
|
549
|
+
"no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)",
|
|
550
|
+
// The host tenant tree fully supports a colocated .tot/config.json: the resolver
|
|
551
|
+
// globs tenants/*/.tot/config.json (config/resolver.ts), copy-tenant-assets bakes
|
|
552
|
+
// ONLY public/ so the .tot dir never ships to served output, and this validator
|
|
553
|
+
// recognizes it above. So this is a real flow-back-readiness gap, NOT an
|
|
554
|
+
// unsupported-layout ask — add the file to close it.
|
|
555
|
+
"add tenants/<id>/.tot/config.json mapping theme.json + content/ + public/ (host-tree layout is supported — .tot/ is never served); see tenants/tokenoftrust.com/.tot/config.json"),
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const scope = opts.scope || config?.scope;
|
|
559
|
+
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
560
|
+
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
561
|
+
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
562
|
+
const platformRoutes = resolvePlatformRouteOwnership(config, hostIsPlatform);
|
|
563
|
+
if (config?.capabilities) {
|
|
564
|
+
findings.push(...validateCapabilitiesDoc(config.capabilities, ".tot/config.json capabilities", config));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
|
|
568
|
+
const themePath = join(tenantDir, "theme.json");
|
|
569
|
+
if (existsSync(themePath)) {
|
|
570
|
+
const { error } = readJsonSafe(themePath);
|
|
571
|
+
if (error) {
|
|
572
|
+
findings.push(
|
|
573
|
+
mk(ERROR, "theme-parse", "theme.json",
|
|
574
|
+
`invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const capabilitiesPath = join(tenantDir, "capabilities.json");
|
|
580
|
+
if (existsSync(capabilitiesPath)) {
|
|
581
|
+
const { value, error } = readJsonSafe(capabilitiesPath);
|
|
582
|
+
if (error) {
|
|
583
|
+
findings.push(mk(ERROR, "capabilities-parse", "capabilities.json", `invalid JSON: ${error}`));
|
|
584
|
+
} else {
|
|
585
|
+
findings.push(...validateCapabilitiesDoc(value, "capabilities.json", config));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const scriptsPath = join(tenantDir, "scripts.json");
|
|
590
|
+
if (existsSync(scriptsPath)) {
|
|
591
|
+
const { value, error } = readJsonSafe(scriptsPath);
|
|
592
|
+
if (error) {
|
|
593
|
+
findings.push(mk(ERROR, "scripts-parse", "scripts.json", `invalid JSON: ${error}`));
|
|
594
|
+
} else {
|
|
595
|
+
findings.push(...validateScriptsDoc(value, "scripts.json", config));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const embedsPath = join(tenantDir, "embeds.json");
|
|
600
|
+
if (existsSync(embedsPath)) {
|
|
601
|
+
const { value, error } = readJsonSafe(embedsPath);
|
|
602
|
+
if (error) {
|
|
603
|
+
findings.push(mk(ERROR, "embeds-parse", "embeds.json", `invalid JSON: ${error}`));
|
|
604
|
+
} else {
|
|
605
|
+
findings.push(...validateEmbedsDoc(value, "embeds.json"));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// 3. content JSON — parse + blocks shape
|
|
610
|
+
for (const name of ["home.json", "chrome.json"]) {
|
|
611
|
+
const p = join(contentDir, name);
|
|
612
|
+
if (existsSync(p)) {
|
|
613
|
+
const { value, error } = readJsonSafe(p);
|
|
614
|
+
if (error) {
|
|
615
|
+
findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
|
|
616
|
+
} else if (name === "home.json") {
|
|
617
|
+
findings.push(...validateHomeDoc(value, "content/home.json"));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// 4. raw HTML documents — validate + portability + resolution
|
|
623
|
+
const hasChrome = providesChrome(contentDir);
|
|
624
|
+
const htmlFiles = walk(contentDir, (p) => p.endsWith(".html"));
|
|
625
|
+
// build the set of resolvable internal page paths (root-absolute → page file)
|
|
626
|
+
const pageTargets = buildPageTargetSet(contentDir, pagesDir);
|
|
627
|
+
|
|
628
|
+
for (const p of htmlFiles) {
|
|
629
|
+
const r = rel(p);
|
|
630
|
+
const html = readFileSync(p, "utf8");
|
|
631
|
+
const base = r === "content/chrome.html";
|
|
632
|
+
|
|
633
|
+
const bad = validateRawHtmlBody(html);
|
|
634
|
+
if (bad) {
|
|
635
|
+
findings.push(mk(ERROR, "html-invalid", r, `not a servable document: ${bad}`));
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (config && !rawHtmlAllowed(config)) {
|
|
639
|
+
findings.push(
|
|
640
|
+
mk(ERROR, "raw-html-compliance-bypass", r,
|
|
641
|
+
"raw HTML bypasses platform Layout compliance; regulated commerce tenants must use block composition or an extracted runtime"),
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (!base && !isFullDocument(html) && !hasChrome) {
|
|
645
|
+
findings.push(
|
|
646
|
+
mk(ERROR, "html-fragment", r,
|
|
647
|
+
"is an HTML fragment but the tenant ships no chrome to wrap it — it will serve unwrapped/naked",
|
|
648
|
+
"make it a full <!doctype html> document, add a content/chrome.json with header.variant/header.brand.label/header.nav and footer.variant/footer.columns, or add content/chrome.html with <!--PAGE_BODY-->"),
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
// A body-only fragment is spliced INTO the tenant's chrome, and that chrome
|
|
652
|
+
// already supplies the skip link. A fragment that kept its own ships two — an
|
|
653
|
+
// a11y defect no browser reports and reconcile cannot see, so it survives every
|
|
654
|
+
// gate and lands in the assembled page.
|
|
655
|
+
if (!base && !isFullDocument(html) && hasChrome && SKIP_LINK_RE.test(html)) {
|
|
656
|
+
findings.push(
|
|
657
|
+
mk(WARN, "duplicate-skip-link", r,
|
|
658
|
+
"keeps its own skip link, but the tenant's chrome already supplies one — the assembled page will have two",
|
|
659
|
+
"delete the page-level skip link; the chrome's skipLink is the one that renders"),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
// CSP: the nonce injector only rewrites a bare `<style>`; `<style type=…>` is skipped → its CSS is dropped.
|
|
663
|
+
if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {
|
|
664
|
+
findings.push(
|
|
665
|
+
mk(WARN, "style-attrs", r,
|
|
666
|
+
"has an inline <style …> with attributes — the CSP nonce injector only matches a bare <style>, so this CSS is dropped when served",
|
|
667
|
+
"use a bare <style> tag"),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// link portability + resolution
|
|
672
|
+
for (const m of html.matchAll(HREF_RE)) {
|
|
673
|
+
const href = m[1].trim();
|
|
674
|
+
findings.push(...checkLink(href, r, scope, pageTargets, platformRoutes.owns));
|
|
675
|
+
}
|
|
676
|
+
// asset existence (src=/tenants/<id>/… or root-absolute /… under public)
|
|
677
|
+
for (const m of html.matchAll(SRC_RE)) {
|
|
678
|
+
const f = checkAsset(m[1].trim(), r, publicDir, opts.tenantId || config?.tenant);
|
|
679
|
+
if (f) findings.push(f);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 5. git conflict markers — advisory (never blocks), but LOUD: a half-resolved
|
|
684
|
+
// merge/rebase must not slip past as "validated". Scans text artifacts under
|
|
685
|
+
// content/ plus the root config files.
|
|
686
|
+
const conflictScanFiles = [
|
|
687
|
+
...walk(contentDir, hasScanExt),
|
|
688
|
+
...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
|
|
689
|
+
.map((f) => join(tenantDir, f))
|
|
690
|
+
.filter((p) => existsSync(p)),
|
|
691
|
+
];
|
|
692
|
+
for (const p of conflictScanFiles) {
|
|
693
|
+
const lines = detectConflictMarkers(readFileSync(p, "utf8"));
|
|
694
|
+
if (lines.length) {
|
|
695
|
+
findings.push(
|
|
696
|
+
mk(WARN, "git-conflict-markers", rel(p),
|
|
697
|
+
`git conflict markers at line(s) ${lines.join(", ")} — looks like an unfinished merge/rebase (the page would still build/serve broken)`,
|
|
698
|
+
"resolve the conflict and remove the <<<<<<< / ======= / >>>>>>> lines before submitting"),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Embed CSP shift-left: scan the tenant's HTML for third-party embeds and
|
|
704
|
+
// check them against the embeds.json opt-in. A known provider not opted in,
|
|
705
|
+
// or an unknown external <script>/<iframe> origin, will be silently blocked by
|
|
706
|
+
// the strict CSP — surface it here (in the dev loop) instead of as a blank
|
|
707
|
+
// spot a human has to notice. Advisory (info) when there's no embeds.json to
|
|
708
|
+
// compare against, since the opt-in may be the platform build-time default.
|
|
709
|
+
if (existsSync(contentDir)) {
|
|
710
|
+
let declaredEmbeds = null;
|
|
711
|
+
const embedsFile = join(tenantDir, "embeds.json");
|
|
712
|
+
if (existsSync(embedsFile)) {
|
|
713
|
+
const { value } = readJsonSafe(embedsFile);
|
|
714
|
+
if (Array.isArray(value)) declaredEmbeds = value.filter((s) => typeof s === "string");
|
|
715
|
+
}
|
|
716
|
+
for (const f of lintTenantEmbeds({ dir: contentDir, declared: declaredEmbeds }).findings) {
|
|
717
|
+
findings.push(mk(f.level, "embed-csp", rel(f.file), f.message));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const ok = !findings.some((f) => f.level === ERROR);
|
|
722
|
+
return { ok, findings };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function buildPageTargetSet(contentDir, pagesDir) {
|
|
726
|
+
const set = new Set(["/", ""]);
|
|
727
|
+
if (existsSync(join(contentDir, "home.html"))) set.add("/");
|
|
728
|
+
for (const p of walk(pagesDir, (x) => x.endsWith(".html"))) {
|
|
729
|
+
// pages-html/foo/bar.html → /foo/bar/ and /foo/bar
|
|
730
|
+
const relPath = relative(pagesDir, p).replace(/\.html$/, "");
|
|
731
|
+
const slug = relPath === "index" ? "" : relPath;
|
|
732
|
+
set.add(`/${slug}/`.replace(/\/+/g, "/"));
|
|
733
|
+
set.add(`/${slug}`.replace(/\/+$/, "") || "/");
|
|
734
|
+
}
|
|
735
|
+
return set;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = (/** @type {string} */ _p) => false) {
|
|
739
|
+
const out = [];
|
|
740
|
+
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
|
|
741
|
+
// localhost never belongs in committed content
|
|
742
|
+
if (/^https?:\/\/localhost(?::\d+)?/i.test(href)) {
|
|
743
|
+
out.push(mk(ERROR, "link-localhost", file, `links to a localhost URL: ${href}`, "use a root-absolute path (/foo/)"));
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
// absolute link back to the tenant's own scope → non-portable (leaves localhost/preview)
|
|
747
|
+
if (scope && new RegExp(`^https?://(www\\.)?${escapeRe(scope)}(/|$)`, "i").test(href)) {
|
|
748
|
+
const path = href.replace(/^https?:\/\/(www\.)?[^/]+/i, "") || "/";
|
|
749
|
+
out.push(
|
|
750
|
+
mk(ERROR, "link-nonportable", file,
|
|
751
|
+
`internal CTA is hardcoded to https://${scope}${path} — it leaves localhost/preview when served`,
|
|
752
|
+
`use the root-absolute form: ${path}`),
|
|
753
|
+
);
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
// other absolute / protocol-relative / mail: external, leave alone
|
|
757
|
+
if (/^([a-z]+:)?\/\//i.test(href)) return out;
|
|
758
|
+
// root-absolute internal link → must resolve to a known page
|
|
759
|
+
if (href.startsWith("/")) {
|
|
760
|
+
if (href.startsWith("/tenants/")) return out; // asset path, not a page
|
|
761
|
+
const path = href.split(/[?#]/)[0];
|
|
762
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
763
|
+
if (pageTargets.has(path) || pageTargets.has(norm) || pageTargets.has(norm + "/")) return out;
|
|
764
|
+
// a framework route the host platform owns (e.g. /collections, /account) is not a dangling tenant page
|
|
765
|
+
if (ownsPlatformRoute(path)) return out;
|
|
766
|
+
out.push(mk(WARN, "link-dangling", file, `internal link ${path} does not resolve to a known page (would 404)`));
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
// relative internal link — discouraged (breaks under path-prefix serving)
|
|
770
|
+
out.push(mk(WARN, "link-relative", file, `relative link "${href}" — internal links should be root-absolute (/foo/)`));
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function checkAsset(src, file, publicDir, tenantId) {
|
|
775
|
+
if (!src || /^([a-z]+:)?\/\//i.test(src) || src.startsWith("data:")) return null;
|
|
776
|
+
let relPath = null;
|
|
777
|
+
if (tenantId && src.startsWith(`/tenants/${tenantId}/`)) {
|
|
778
|
+
relPath = src.slice(`/tenants/${tenantId}/`.length).split(/[?#]/)[0];
|
|
779
|
+
} else if (src.startsWith("/")) {
|
|
780
|
+
return null; // some other root-absolute asset (served from build public root) — out of scope
|
|
781
|
+
} else {
|
|
782
|
+
relPath = src.split(/[?#]/)[0];
|
|
783
|
+
}
|
|
784
|
+
if (!relPath) return null;
|
|
785
|
+
if (!existsSync(join(publicDir, relPath))) {
|
|
786
|
+
return mk(WARN, "asset-missing", file, `references public asset "${src}" which is not present under public/`);
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
792
|
+
|
|
793
|
+
// --- CLI ---------------------------------------------------------------------
|
|
794
|
+
function resolveTenantDir(argv) {
|
|
795
|
+
const wsIdx = argv.indexOf("--workspace");
|
|
796
|
+
if (wsIdx !== -1) {
|
|
797
|
+
const dir = argv[wsIdx + 1];
|
|
798
|
+
if (!dir) return { error: "--workspace requires a path" };
|
|
799
|
+
return { dir: resolve(dir), tenantId: null };
|
|
800
|
+
}
|
|
801
|
+
const id = argv.find((a) => !a.startsWith("--"));
|
|
802
|
+
if (!id) return { error: "usage: node scripts/tenant/validate.mjs <tenant> | --workspace <dir> [--json]" };
|
|
803
|
+
return { dir: tenantDir(id), tenantId: id };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const isMain = resolve(process.argv[1] || "") === resolve(fileURLToPath(import.meta.url));
|
|
807
|
+
if (isMain) {
|
|
808
|
+
const argv = process.argv.slice(2);
|
|
809
|
+
const asJson = argv.includes("--json");
|
|
810
|
+
const { dir, tenantId, error } = resolveTenantDir(argv);
|
|
811
|
+
if (error) {
|
|
812
|
+
console.error(error);
|
|
813
|
+
process.exit(2);
|
|
814
|
+
}
|
|
815
|
+
if (!dir) process.exit(2);
|
|
816
|
+
if (!existsSync(dir)) {
|
|
817
|
+
console.error(`✗ no tenant directory at ${dir}`);
|
|
818
|
+
process.exit(2);
|
|
819
|
+
}
|
|
820
|
+
const mode = argv.includes("--workspace") ? "workspace" : "monorepo";
|
|
821
|
+
const { ok, findings } = validateTenant(dir, { tenantId: tenantId ?? undefined, mode });
|
|
822
|
+
if (asJson) {
|
|
823
|
+
console.log(JSON.stringify({ ok, findings }, null, 2));
|
|
824
|
+
process.exit(ok ? 0 : 1);
|
|
825
|
+
}
|
|
826
|
+
const errors = findings.filter((f) => f.level === ERROR);
|
|
827
|
+
const warns = findings.filter((f) => f.level === WARN);
|
|
828
|
+
console.log(`\ntot validate — ${tenantId ?? dir}\n`);
|
|
829
|
+
for (const f of findings) {
|
|
830
|
+
const tag = f.level === ERROR ? "✗" : "⚠";
|
|
831
|
+
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
|
832
|
+
}
|
|
833
|
+
console.log(
|
|
834
|
+
`\n${errors.length === 0 ? "✔" : "✖"} ${errors.length} error(s), ${warns.length} warning(s).\n`,
|
|
835
|
+
);
|
|
836
|
+
process.exit(errors.length === 0 ? 0 : 1);
|
|
837
|
+
}
|