minutework 0.1.6 → 0.1.8

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 (40) hide show
  1. package/EXTERNAL_ALPHA.md +18 -1
  2. package/README.md +9 -2
  3. package/assets/claude-local/CLAUDE.md.template +22 -2
  4. package/assets/claude-local/skills/README.md +9 -0
  5. package/assets/claude-local/skills/app-pack-authoring.md +5 -1
  6. package/assets/claude-local/skills/content-structure-and-sections.md +15 -0
  7. package/assets/claude-local/skills/published-web-and-mw-core-site.md +17 -0
  8. package/assets/claude-local/skills/schema-engine.md +4 -0
  9. package/assets/claude-local/skills/secrets-runtime-bridge.md +3 -0
  10. package/assets/claude-local/skills/sidecar-generation.md +4 -0
  11. package/assets/templates/fastapi-sidecar/template.schema.json +2 -1
  12. package/assets/templates/next-tenant-app/.storybook/main.ts +1 -1
  13. package/assets/templates/next-tenant-app/README.md +20 -3
  14. package/assets/templates/next-tenant-app/next.config.mjs +33 -0
  15. package/assets/templates/next-tenant-app/package.json +3 -1
  16. package/assets/templates/next-tenant-app/public/.gitkeep +1 -0
  17. package/assets/templates/next-tenant-app/src/app/(cms)/[...path]/page.tsx +73 -0
  18. package/assets/templates/next-tenant-app/src/features/dashboard/components/tenant-dashboard.tsx +5 -3
  19. package/assets/templates/next-tenant-app/src/features/public-shell/components/section-renderer.test.ts +38 -0
  20. package/assets/templates/next-tenant-app/src/features/public-shell/components/section-renderer.tsx +145 -0
  21. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +2 -2
  22. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +47 -1
  23. package/assets/templates/next-tenant-app/src/lib/content/contracts.test.ts +138 -0
  24. package/assets/templates/next-tenant-app/src/lib/content/contracts.ts +59 -0
  25. package/assets/templates/next-tenant-app/src/lib/content/release-manifest.test.ts +70 -0
  26. package/assets/templates/next-tenant-app/src/lib/content/release-manifest.ts +106 -0
  27. package/assets/templates/next-tenant-app/template.json +2 -2
  28. package/assets/templates/next-tenant-app/template.schema.json +2 -1
  29. package/assets/templates/next-tenant-app/tools/template/with-public-site-fixture.mjs +130 -0
  30. package/dist/agent.d.ts +19 -0
  31. package/dist/agent.js +308 -0
  32. package/dist/agent.js.map +1 -0
  33. package/dist/developer-client.d.ts +59 -0
  34. package/dist/developer-client.js +35 -0
  35. package/dist/developer-client.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.js +17 -0
  38. package/dist/index.js.map +1 -1
  39. package/package.json +1 -1
  40. package/assets/templates/next-tenant-app/next.config.ts +0 -18
package/EXTERNAL_ALPHA.md CHANGED
@@ -15,6 +15,10 @@ npx minutework link
15
15
 
16
16
  From there, the current external alpha supports two post-link lanes.
17
17
 
18
+ The shipped `tenant-app` starter is the combined web surface: public routes at
19
+ the root, a private `/app` workspace, and a MinuteWork-managed public-content
20
+ path over the runtime-baseline `mw.core.site` contract.
21
+
18
22
  ### Developer-local broker lane
19
23
 
20
24
  Use `minutework session` when you want MinuteWork to assemble the workspace
@@ -54,6 +58,12 @@ The broker fails closed when a live session is already active, the selected
54
58
  engine is missing, a prior running record has gone stale, or the launch is
55
59
  interrupted or fails mid-session.
56
60
 
61
+ Generated workspaces receive a root `CLAUDE.md` plus exported `skills/`
62
+ guidance so the local coding agent sees the combined-web, snapshot-delivery,
63
+ and `mw.core.site` baseline workflow.
64
+
65
+ Runtime-only Claude hooks remain excluded from the generated workspace export.
66
+
57
67
  ### Local preview and deploy lane
58
68
 
59
69
  Use the existing local preview/test loop and hosted preview deploy lane when
@@ -89,10 +99,15 @@ If you run `minutework init` **without** `--starter` in an interactive terminal,
89
99
  The generated `tenant-app/.env.example` defaults to:
90
100
 
91
101
  ```dotenv
102
+ MW_CONTENT_API_TOKEN=
92
103
  MW_PUBLIC_SITE_PROPERTY_KEY=main-site
93
104
  MW_PUBLIC_SITE_ENV=preview
94
105
  ```
95
106
 
107
+ `MW_CONTENT_API_TOKEN` stays server-only. `MW_PUBLIC_SITE_ENV=preview` targets
108
+ preview or draft-safe reads, while `MW_PUBLIC_SITE_ENV=live` is publication-safe
109
+ and intended for published snapshot delivery.
110
+
96
111
  If the workspace cannot compile hosted preview release metadata for the linked property, deploy fails closed.
97
112
 
98
113
  ## What `deploy --preview` does
@@ -136,4 +151,6 @@ The CLI does not fabricate success. If the backend cannot materialize a hosted p
136
151
  - Package versioning stays manual for the first alpha.
137
152
  - Publish with npm dist-tag `alpha`.
138
153
  - Use the manual GitHub Actions workflow at `.github/workflows/minutework-cli-alpha-publish.yml`.
139
- - Keep dry-run enabled until the package version and changelog are ready.
154
+ - Treat `pnpm --dir packages/minutework-cli smoke:starter` as a hard publish
155
+ gate alongside package tests and build, because the workflow does not run it
156
+ automatically.
package/README.md CHANGED
@@ -12,6 +12,10 @@ The current external alpha is intentionally narrow:
12
12
  - Deploy surface: `minutework deploy --preview`
13
13
  - Deferred: `--live`, additional local coding engines, sidecar/runtime-backed deploys, marketplace publish flows
14
14
 
15
+ The shipped `tenant-app` starter is the combined web surface: public routes at
16
+ the root plus a private `/app` workspace. Public-site content follows the
17
+ MinuteWork-managed `mw.core.site` baseline and published-snapshot flow.
18
+
15
19
  The CLI fails closed when the backend cannot provide typed release metadata, deploy status, receipts, activation state, or rollback state. It does not claim a successful deploy when the provider substrate is unavailable.
16
20
 
17
21
  ## Install
@@ -70,6 +74,9 @@ minutework session resume
70
74
  - The broker fails closed on live overlap, missing engine binaries, stale
71
75
  session records, interrupts, and launch failures instead of allowing
72
76
  overlapping phantom sessions.
77
+ - Generated workspaces receive a root `CLAUDE.md` plus exported `skills/`
78
+ guidance tailored to the combined web and published-site workflow.
79
+ - Runtime-only Claude hooks are not exported into the generated workspace.
73
80
 
74
81
  ### Local preview and deploy lane
75
82
 
@@ -84,9 +91,9 @@ minutework deploy --preview
84
91
 
85
92
  Workspaces created with **both** `tenant-app` and `sidecar` run `poetry install` in `sidecar` automatically on `pnpm install` (requires [Poetry](https://python-poetry.org/) on your `PATH`). Sidecar-only scaffolds have no root `package.json`; run `poetry install` in `sidecar` once before `minutework dev`.
86
93
 
87
- `link` provisions or resolves the default published-site property for the active tenant and stores that property key in repo-local state. The generated `tenant-app/.env.example` defaults to `MW_PUBLIC_SITE_PROPERTY_KEY=main-site` and `MW_PUBLIC_SITE_ENV=preview`.
94
+ `link` provisions or resolves the default published-site property for the active tenant and stores that property key in repo-local state. The generated `tenant-app/.env.example` includes the standard site env contract: `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and `MW_PUBLIC_SITE_ENV`. It defaults to `MW_PUBLIC_SITE_PROPERTY_KEY=main-site` and `MW_PUBLIC_SITE_ENV=preview`.
88
95
 
89
- `deploy --preview` always revalidates and recompiles before submit, prints a local-vs-remote diff, requires confirmation unless `--yes` is passed, polls typed receipts until a terminal state, and persists the last known preview deploy state under `.minutework/deploy/preview/status.json`.
96
+ `deploy --preview` always revalidates and recompiles before submit, prints a local-vs-remote diff, requires confirmation unless `--yes` is passed, polls typed receipts until a terminal state, and persists the last known preview deploy state under `.minutework/deploy/preview/status.json`. This alpha is preview-first; live public delivery remains follow-on.
90
97
 
91
98
  ## Bug reporting
92
99
 
@@ -4,6 +4,9 @@ Use this workspace to build or extend MinuteWork app packs. `tenant-app` and
4
4
  `sidecar` are implementation surfaces inside the pack; choose the smallest
5
5
  surface that fits the request.
6
6
 
7
+ `tenant-app` is the combined web starter: public site routes at the root plus a
8
+ private authenticated workspace under `/app`.
9
+
7
10
  ## Starter Choice Matrix
8
11
 
9
12
  The shipped product unit is an `app pack`. `tenant-app` and `sidecar` are
@@ -32,14 +35,31 @@ a concrete backend responsibility such as:
32
35
  - internal API endpoint
33
36
  - external system sync
34
37
 
38
+ ## Public Site Defaults
39
+
40
+ - Treat `mw.core.site` as a runtime baseline capability that already exists. Do
41
+ not model site work as "installing" or "reconciling" the baseline.
42
+ - Author public content against runtime/CMS records and published-web flows, not
43
+ against a fixed in-repo marketing template.
44
+ - Use `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and
45
+ `MW_PUBLIC_SITE_ENV` as the standard site env contract.
46
+ - `MW_PUBLIC_SITE_ENV=preview` is for preview or draft-safe reads.
47
+ - `MW_PUBLIC_SITE_ENV=live` is for publication-safe delivery.
48
+ - Anonymous live delivery should prefer published snapshots instead of direct
49
+ runtime reads on every request.
50
+ - `content_structure` is open and ordered. Extend it through adjacent schemas or
51
+ extension packs when needed instead of bloating the baseline.
52
+
35
53
  ## Hard Rules
36
54
 
37
55
  - Do not put browser auth or tenant-facing login flows in `sidecar`.
38
56
  - Do not put heavy compute, workers, or integration orchestration in `tenant-app` route handlers.
39
57
  - Do not choose `both` by default unless the app clearly has both UI and backend-runtime responsibilities.
40
58
  - Public anonymous pages usually belong in `tenant-app` and ship through the hosted public-release path, not a runtime-local sidecar.
59
+ - Do not use `sidecar` as the default public-site renderer for pricing, docs,
60
+ blog, or landing pages.
41
61
  - `sidecar` is internal-first by default.
42
62
 
43
63
  See the files under `skills/` for deeper guidance on schema authoring,
44
- app-pack structure, sidecar generation, event flows, ontology mapping, and
45
- import boundaries.
64
+ app-pack structure, published web, content sections, sidecar generation, event
65
+ flows, ontology mapping, and import boundaries.
@@ -4,3 +4,12 @@ Place focused MinuteWork architecture skills here.
4
4
 
5
5
  These files are part of the canonical Builder common bundle and are allowlisted
6
6
  for developer-local export. Skills are shared across all engine renderers.
7
+
8
+ They should describe the shipped authoring model external workspaces actually
9
+ receive:
10
+
11
+ - `tenant-app` is the combined public plus private web starter
12
+ - `mw.core.site` is a runtime baseline capability
13
+ - public authoring is runtime/CMS-backed
14
+ - anonymous live delivery should prefer published snapshots
15
+ - tenant-specific extensions belong in adjacent schemas or extension packs
@@ -3,6 +3,10 @@
3
3
  An `app pack` is the shipped product unit.
4
4
 
5
5
  - Start with declarative schema/manifests and add code surfaces only when needed.
6
- - Use `tenant-app` for web UI/BFF concerns.
6
+ - Use `tenant-app` for the combined public-site plus private-app web surface.
7
7
  - Use `sidecar` for internal APIs, workers, integrations, or Python-heavy compute.
8
+ - Treat `mw.core.site` as a runtime baseline capability and compose against it
9
+ instead of trying to install it from the workspace.
10
+ - Public-site authoring should stay CMS/runtime-backed, while anonymous live
11
+ delivery should prefer published snapshots.
8
12
  - Keep platform-owned runtime source untouched; author only workspace-local generated source.
@@ -0,0 +1,15 @@
1
+ # Content Structure And Sections
2
+
3
+ Use this skill when the request touches CMS page models, section rendering, or
4
+ public content composition.
5
+
6
+ - Treat `content_structure` as an open, ordered section graph, not a closed
7
+ fixed-template schema.
8
+ - Section types should stay extensible strings so tenant-specific or extension
9
+ pack sections can be added without rewriting the baseline.
10
+ - Keep ordering explicit with stable page and section sort fields where the
11
+ contract expects them.
12
+ - Map `section_type` values to UI components in the web surface instead of
13
+ collapsing everything into one page model.
14
+ - Put tenant-specific additions in adjacent schemas or extension packs instead
15
+ of bloating `mw.core.site`.
@@ -0,0 +1,17 @@
1
+ # Published Web And mw.core.site
2
+
3
+ Use this skill when the request touches public-site delivery, published-web
4
+ flows, or the default MinuteWork site model.
5
+
6
+ - Treat `mw.core.site` as a runtime baseline capability that already exists.
7
+ - `tenant-app` is the combined web starter for public routes plus the private
8
+ `/app` workspace.
9
+ - Author public content against runtime/CMS records, not a fixed in-repo site
10
+ starter.
11
+ - Use `published-web` and hosted public-release flows for anonymous delivery.
12
+ - `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and
13
+ `MW_PUBLIC_SITE_ENV` are the standard site env variables.
14
+ - `MW_PUBLIC_SITE_ENV=preview` is for preview or draft-safe reads.
15
+ - `MW_PUBLIC_SITE_ENV=live` is for publication-safe reads only.
16
+ - Anonymous live traffic should prefer published snapshots instead of direct
17
+ runtime reads on every request.
@@ -6,3 +6,7 @@ Use this skill when the request needs tenant-defined data structures.
6
6
  - Prefer declarative schema changes before adding custom code surfaces.
7
7
  - Index only the fields that need query/filter behavior.
8
8
  - Treat runtime-backed content as the authoring source; publish safe snapshots for anonymous public delivery.
9
+ - Treat `mw.core.site` as already present in the runtime baseline.
10
+ - Keep `content_structure` open and ordered. Extend the baseline through
11
+ adjacent schemas or extension packs instead of hard-coding a fixed page
12
+ template into the schema layer.
@@ -7,3 +7,6 @@ data access.
7
7
  - Prefer `secret_ref` style bindings over plaintext credentials.
8
8
  - Treat bridge access as explicit and narrow; do not turn it into a generic data bypass.
9
9
  - Keep private runtime payloads, traces, and raw tenant data out of public or control-plane surfaces unless a contract explicitly allows projection.
10
+ - Keep `MW_CONTENT_API_TOKEN` server-only.
11
+ - Do not leak preview-only or runtime-private site reads into anonymous live
12
+ delivery; published snapshots are the safe default for that boundary.
@@ -5,5 +5,9 @@ belong in `tenant-app`.
5
5
 
6
6
  - Good fits: webhook handlers, workers, schedulers, ingestion, internal APIs, Python libraries.
7
7
  - Keep browser auth and tenant-facing login flows in `tenant-app`, not here.
8
+ - Keep public-site rendering for pricing, docs, blog, and landing pages in
9
+ `tenant-app`, not here.
10
+ - Do not use sidecar routes as the default anonymous live delivery path when
11
+ published snapshots or hosted public delivery are the intended contract.
8
12
  - Treat the sidecar as internal-first by default.
9
13
  - Do not couple sidecar code directly to the runtime database.
@@ -24,7 +24,8 @@
24
24
  "type": "string",
25
25
  "enum": [
26
26
  "sidecar_nextjs_private",
27
- "sidecar_fastapi_internal"
27
+ "sidecar_fastapi_internal",
28
+ "combined_web"
28
29
  ]
29
30
  },
30
31
  "template_profile": {
@@ -9,7 +9,7 @@ export default defineMain({
9
9
  framework: {
10
10
  name: "@storybook/nextjs-vite",
11
11
  options: {
12
- nextConfigPath: "../next.config.ts",
12
+ nextConfigPath: "../next.config.mjs",
13
13
  },
14
14
  },
15
15
  staticDirs: ["../public"],
@@ -1,6 +1,12 @@
1
1
  # Next Tenant App Template
2
2
 
3
- This directory is the canonical combined Next.js web starter for Builder. It is the bundle that Builder will later materialize into `BuilderWorkspace.sandbox_root/app/`.
3
+ This directory is the canonical combined Next.js web starter for Builder.
4
+
5
+ In the runtime Builder flow, this bundle materializes into
6
+ `BuilderWorkspace.sandbox_root/app/`.
7
+
8
+ In the external CLI scaffold flow, the same starter is copied into
9
+ `tenant-app/`.
4
10
 
5
11
  `apps/mw-next-client` is still the repo-side seed used to harden and evolve this scaffold. It is not the runtime template location.
6
12
 
@@ -37,6 +43,9 @@ The default built-in adapter is `MinuteWorkPublicSiteContentAdapter`. It calls t
37
43
  MinuteWork gateway with a server-only content token and reads the narrow
38
44
  `mw.core.site` public-site snapshot contract.
39
45
 
46
+ `mw.core.site` is treated as a runtime baseline capability. This starter
47
+ composes against that baseline rather than trying to install it locally.
48
+
40
49
  The gateway response carries an explicit boundary:
41
50
 
42
51
  - `environment=preview` must declare `source_boundary=runtime_preview`
@@ -105,11 +114,19 @@ Use `pnpm validate` from this directory. It runs:
105
114
  6. `typecheck`
106
115
  7. `lint`
107
116
  8. `test`
108
- 9. `build`
109
- 10. `build-storybook`
117
+ 9. `build:validate`
118
+ 10. `build-storybook:validate`
110
119
 
111
120
  `template:validate` validates `template.json` against the bundled `template.schema.json`, so the template remains self-validating after Builder materializes it into a workspace sandbox.
112
121
 
122
+ When the CLI scaffolds this starter into `tenant-app/`, the same validation
123
+ contract still applies to that copied workspace surface.
124
+
125
+ The validation-only build steps inject fixture values for the required
126
+ production env vars and run against a local public-site fixture server, so the
127
+ template can prove its build contract in-repo without weakening the real
128
+ `pnpm build` requirement.
129
+
113
130
  `template:route-contract` verifies that the combined public/private route tree, metadata routes, and content-adapter entrypoints remain present in the template.
114
131
 
115
132
  Inside the repo, the shared schema at `runtime/builder/templates/template.schema.json` is authoritative. The bundled `template.schema.json` inside this template must stay byte-equivalent, and `template:validate` fails if the two drift.
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { existsSync } from "node:fs";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ function resolveTurbopackRoot(startDirectory) {
8
+ let directory = startDirectory;
9
+
10
+ while (true) {
11
+ if (existsSync(path.join(directory, "pnpm-workspace.yaml"))) {
12
+ return directory;
13
+ }
14
+
15
+ const parent = path.dirname(directory);
16
+ if (parent === directory) {
17
+ return startDirectory;
18
+ }
19
+ directory = parent;
20
+ }
21
+ }
22
+
23
+ const nextConfig = {
24
+ reactStrictMode: true,
25
+ typedRoutes: true,
26
+ // Prefer the nearest workspace root so scaffolded tenant-app packages inside a
27
+ // monorepo and the in-repo template both resolve dependencies correctly.
28
+ turbopack: {
29
+ root: resolveTurbopackRoot(__dirname),
30
+ },
31
+ };
32
+
33
+ export default nextConfig;
@@ -9,6 +9,7 @@
9
9
  "scripts": {
10
10
  "dev": "next dev --hostname 127.0.0.1 --port 3000",
11
11
  "build": "next build",
12
+ "build:validate": "node tools/template/with-public-site-fixture.mjs next build",
12
13
  "start": "next start -p 3000",
13
14
  "lint": "eslint .",
14
15
  "test": "vitest run",
@@ -16,12 +17,13 @@
16
17
  "template:validate": "node tools/template/validate-template.mjs",
17
18
  "template:route-contract": "node tools/template/validate-route-contract.mjs",
18
19
  "typecheck": "tsc --noEmit",
19
- "validate": "pnpm template:validate && pnpm template:route-contract && pnpm typegen && pnpm design-system:tokens && pnpm design-system:check && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm build-storybook",
20
+ "validate": "pnpm template:validate && pnpm template:route-contract && pnpm typegen && pnpm design-system:tokens && pnpm design-system:check && pnpm typecheck && pnpm lint && pnpm test && pnpm build:validate && pnpm build-storybook:validate",
20
21
  "design-system:tokens": "node tools/design-system/build-token-manifest.mjs",
21
22
  "design-system:check": "node tools/design-system/run-checks.mjs",
22
23
  "design-system:check:staged": "node tools/design-system/run-checks.mjs --staged",
23
24
  "storybook": "storybook dev -p 6006",
24
25
  "build-storybook": "storybook build",
26
+ "build-storybook:validate": "node tools/template/with-public-site-fixture.mjs storybook build",
25
27
  "design-system:visual": "playwright test -c tools/design-system/playwright.config.mjs"
26
28
  },
27
29
  "dependencies": {
@@ -0,0 +1,73 @@
1
+ import type { Metadata } from "next";
2
+ import { notFound } from "next/navigation";
3
+
4
+ import { ContentStructureRenderer } from "@/features/public-shell/components/section-renderer";
5
+ import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
6
+ import { getPageByPath, getSiteConfig, listPages } from "@/lib/content/adapter.server";
7
+ import { buildPublicMetadata } from "@/lib/public-site";
8
+
9
+ type PageParams = { path: string[] };
10
+
11
+ export async function generateStaticParams(): Promise<PageParams[]> {
12
+ const pages = await listPages("published");
13
+ return pages
14
+ .filter((p) => p.path !== "/")
15
+ .map((p) => ({
16
+ path: p.path.replace(/^\//, "").split("/").filter(Boolean),
17
+ }));
18
+ }
19
+
20
+ export async function generateMetadata({
21
+ params,
22
+ }: {
23
+ params: Promise<PageParams>;
24
+ }): Promise<Metadata> {
25
+ const { path: pathSegments } = await params;
26
+ const pagePath = `/${pathSegments.join("/")}`;
27
+ const page = await getPageByPath(pagePath);
28
+
29
+ if (!page) return {};
30
+
31
+ const seo = page.seo_metadata;
32
+ return buildPublicMetadata({
33
+ title: seo?.title ?? page.title,
34
+ description: seo?.description ?? page.title,
35
+ path: pagePath,
36
+ });
37
+ }
38
+
39
+ export default async function CmsPage({
40
+ params,
41
+ }: {
42
+ params: Promise<PageParams>;
43
+ }) {
44
+ const { path: pathSegments } = await params;
45
+ const pagePath = `/${pathSegments.join("/")}`;
46
+ const [siteConfig, page] = await Promise.all([
47
+ getSiteConfig(),
48
+ getPageByPath(pagePath),
49
+ ]);
50
+
51
+ if (!page || page.status === "draft") {
52
+ notFound();
53
+ }
54
+
55
+ const sections = page.content_structure?.sections ?? [];
56
+
57
+ return (
58
+ <PublicSiteShell siteConfig={siteConfig} activeHref={pagePath}>
59
+ <div className="space-y-8">
60
+ <div className="space-y-3">
61
+ <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
62
+ {page.title}
63
+ </h1>
64
+ </div>
65
+ {sections.length > 0 ? (
66
+ <ContentStructureRenderer sections={sections} />
67
+ ) : (
68
+ <p className="text-muted-foreground">This page has no content yet.</p>
69
+ )}
70
+ </div>
71
+ </PublicSiteShell>
72
+ );
73
+ }
@@ -96,9 +96,11 @@ export function TenantDashboard({
96
96
  </div>
97
97
  <p className="text-sm leading-7 text-muted-foreground">
98
98
  The canonical template bundle is meant to be copied into
99
- `BuilderWorkspace.sandbox_root/app/` before an agent edits it. The
100
- copied workspace becomes the writable app root; this source bundle stays
101
- governed and read-only in normal authoring flows.
99
+ a writable workspace copy before an agent edits it. In runtime
100
+ Builder flows that writable root lives at
101
+ `BuilderWorkspace.sandbox_root/app/`; in CLI scaffolds the same
102
+ starter lands in `tenant-app/`. This source bundle stays governed
103
+ and read-only in normal authoring flows.
102
104
  </p>
103
105
  </PanelFrame>
104
106
  </section>
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { ContentSection } from "@/lib/content/contracts";
4
+
5
+ describe("section-renderer", () => {
6
+ it("sorts sections by sort_order", async () => {
7
+ const sections: ContentSection[] = [
8
+ { section_key: "b", section_type: "text", sort_order: 2, data: { heading: "B" } },
9
+ { section_key: "a", section_type: "text", sort_order: 0, data: { heading: "A" } },
10
+ { section_key: "c", section_type: "text", sort_order: 1, data: { heading: "C" } },
11
+ ];
12
+
13
+ const sorted = [...sections].sort(
14
+ (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
15
+ );
16
+
17
+ expect(sorted.map((s) => s.section_key)).toEqual(["a", "c", "b"]);
18
+ });
19
+
20
+ it("accepts arbitrary section types without error", () => {
21
+ const sections: ContentSection[] = [
22
+ {
23
+ section_key: "custom",
24
+ section_type: "my_custom_widget",
25
+ data: { arbitrary: "tenant-defined", nested: { deep: true } },
26
+ },
27
+ {
28
+ section_key: "hero",
29
+ section_type: "hero_banner",
30
+ data: { heading: "Welcome" },
31
+ },
32
+ ];
33
+
34
+ // Content types are open strings — no validation error.
35
+ expect(sections.length).toBe(2);
36
+ expect(sections[0]!.section_type).toBe("my_custom_widget");
37
+ });
38
+ });
@@ -0,0 +1,145 @@
1
+ import { PanelFrame } from "@/design-system/patterns/panel-frame";
2
+ import type { ContentSection } from "@/lib/content/contracts";
3
+
4
+ /**
5
+ * Registry of section_type -> React component.
6
+ *
7
+ * Section types are extensible strings. Unknown types render a
8
+ * generic fallback. Tenant-specific or extension-pack sections
9
+ * can be added here without modifying the baseline.
10
+ */
11
+
12
+ function HeroSection({ section }: { section: ContentSection }) {
13
+ const { heading, subheading, body, cta_label, cta_href } = section.data as Record<
14
+ string,
15
+ string | undefined
16
+ >;
17
+ return (
18
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="space-y-4 border-border/70">
19
+ <div className="space-y-3">
20
+ {subheading ? (
21
+ <p className="text-sm font-semibold uppercase tracking-[0.28em] text-muted-foreground">
22
+ {subheading}
23
+ </p>
24
+ ) : null}
25
+ <h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
26
+ {heading || section.section_key}
27
+ </h2>
28
+ {body ? <p className="max-w-2xl text-base leading-8 text-muted-foreground">{body}</p> : null}
29
+ </div>
30
+ {cta_label && cta_href ? (
31
+ <a
32
+ href={cta_href}
33
+ className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
34
+ >
35
+ {cta_label}
36
+ </a>
37
+ ) : null}
38
+ </PanelFrame>
39
+ );
40
+ }
41
+
42
+ function TextSection({ section }: { section: ContentSection }) {
43
+ const { heading, body, eyebrow } = section.data as Record<string, string | undefined>;
44
+ return (
45
+ <PanelFrame tone="raised" radius="xl" padding="lg" className="space-y-3 border-border/70">
46
+ {eyebrow ? (
47
+ <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
48
+ {eyebrow}
49
+ </p>
50
+ ) : null}
51
+ <h3 className="text-xl font-semibold tracking-tight">{heading || section.section_key}</h3>
52
+ {body ? <p className="text-sm leading-7 text-muted-foreground">{body}</p> : null}
53
+ </PanelFrame>
54
+ );
55
+ }
56
+
57
+ function FeatureGridSection({ section }: { section: ContentSection }) {
58
+ const { heading, items } = section.data as {
59
+ heading?: string;
60
+ items?: { title: string; description?: string; icon?: string }[];
61
+ };
62
+ return (
63
+ <div className="space-y-4">
64
+ {heading ? <h3 className="text-xl font-semibold tracking-tight">{heading}</h3> : null}
65
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
66
+ {(items ?? []).map((item) => (
67
+ <PanelFrame
68
+ key={item.title}
69
+ tone="raised"
70
+ radius="xl"
71
+ padding="lg"
72
+ className="space-y-2 border-border/70"
73
+ >
74
+ <h4 className="font-semibold">{item.title}</h4>
75
+ {item.description ? (
76
+ <p className="text-sm text-muted-foreground">{item.description}</p>
77
+ ) : null}
78
+ </PanelFrame>
79
+ ))}
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ function GenericSection({ section }: { section: ContentSection }) {
86
+ const { heading, body, eyebrow } = section.data as Record<string, string | undefined>;
87
+ return (
88
+ <PanelFrame tone="raised" radius="xl" padding="lg" className="space-y-3 border-border/70">
89
+ {eyebrow ? (
90
+ <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
91
+ {eyebrow}
92
+ </p>
93
+ ) : null}
94
+ <h3 className="text-xl font-semibold tracking-tight">
95
+ {heading || section.section_type}
96
+ </h3>
97
+ {body ? <p className="text-sm leading-7 text-muted-foreground">{body}</p> : null}
98
+ </PanelFrame>
99
+ );
100
+ }
101
+
102
+ const SECTION_RENDERERS: Record<
103
+ string,
104
+ (props: { section: ContentSection }) => React.ReactNode
105
+ > = {
106
+ hero_banner: HeroSection,
107
+ hero: HeroSection,
108
+ text: TextSection,
109
+ text_block: TextSection,
110
+ feature_grid: FeatureGridSection,
111
+ };
112
+
113
+ /**
114
+ * Render a single content section by its section_type.
115
+ *
116
+ * Known types get specialized renderers. Unknown types get a generic
117
+ * fallback that renders heading + body from the section data.
118
+ */
119
+ export function SectionRenderer({ section }: { section: ContentSection }) {
120
+ const Renderer = SECTION_RENDERERS[section.section_type] ?? GenericSection;
121
+ return <Renderer section={section} />;
122
+ }
123
+
124
+ /**
125
+ * Render an ordered list of content sections from a page's content_structure.
126
+ *
127
+ * Sections are sorted by sort_order (if present), then rendered in order.
128
+ */
129
+ export function ContentStructureRenderer({
130
+ sections,
131
+ }: {
132
+ sections: ContentSection[];
133
+ }) {
134
+ const sorted = [...sections].sort(
135
+ (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
136
+ );
137
+
138
+ return (
139
+ <div className="grid gap-6">
140
+ {sorted.map((section) => (
141
+ <SectionRenderer key={section.section_key} section={section} />
142
+ ))}
143
+ </div>
144
+ );
145
+ }
@@ -302,9 +302,9 @@ describe("public content adapter", () => {
302
302
  }
303
303
 
304
304
  return publicSiteFixtureSnapshot.blog.map((entry) => ({
305
- ...entry,
305
+ ...toBlogEntrySummary(entry),
306
306
  slug: ["release", "v1"],
307
- }));
307
+ })) as unknown as Awaited<ReturnType<PublicContentAdapter["listEntries"]>>;
308
308
  },
309
309
  }),
310
310
  }));