diff --git a/README.md b/README.md index dbda19f50..698655e28 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ A full-stack plugin can bring the routes, APIs, database schema, hooks, SSR-awar **You own the whole application.** Your app, data, deployment, and ejected UI stay yours. BTST runs inside your stack as an open-source dependency you can inspect, fork, or replace—never as a required hosted control plane. +![BTST promise above a focused view of the real Blog route from the generated application.](docs/assets/product-proof/readme-hero.webp) + +Real output from the repository's generated Next.js application. + [![npm](https://img.shields.io/npm/v/@btst/stack.svg)](https://www.npmjs.com/package/@btst/stack) [![MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/docs/assets/architecture.png b/docs/assets/architecture.png deleted file mode 100644 index eaea7b9fd..000000000 Binary files a/docs/assets/architecture.png and /dev/null differ diff --git a/docs/assets/blog-demo-1.png b/docs/assets/blog-demo-1.png deleted file mode 100644 index 98422e45c..000000000 Binary files a/docs/assets/blog-demo-1.png and /dev/null differ diff --git a/docs/assets/blog-demo-2.png b/docs/assets/blog-demo-2.png deleted file mode 100644 index 7ba8d5f32..000000000 Binary files a/docs/assets/blog-demo-2.png and /dev/null differ diff --git a/docs/assets/blog-demo-3.png b/docs/assets/blog-demo-3.png deleted file mode 100644 index bb48ae270..000000000 Binary files a/docs/assets/blog-demo-3.png and /dev/null differ diff --git a/docs/assets/blog-demo.png b/docs/assets/blog-demo.png deleted file mode 100644 index e59b34907..000000000 Binary files a/docs/assets/blog-demo.png and /dev/null differ diff --git a/docs/assets/chat-demo-1.png b/docs/assets/chat-demo-1.png deleted file mode 100644 index 6f55d293f..000000000 Binary files a/docs/assets/chat-demo-1.png and /dev/null differ diff --git a/docs/assets/chat-demo.png b/docs/assets/chat-demo.png deleted file mode 100644 index fbddf4070..000000000 Binary files a/docs/assets/chat-demo.png and /dev/null differ diff --git a/docs/assets/form-builder-demo-1.png b/docs/assets/form-builder-demo-1.png deleted file mode 100644 index 1ca2f2afb..000000000 Binary files a/docs/assets/form-builder-demo-1.png and /dev/null differ diff --git a/docs/assets/form-builder-demo-2.png b/docs/assets/form-builder-demo-2.png deleted file mode 100644 index cdaf4808f..000000000 Binary files a/docs/assets/form-builder-demo-2.png and /dev/null differ diff --git a/docs/assets/form-builder-demo-3.png b/docs/assets/form-builder-demo-3.png deleted file mode 100644 index 79dd2dc5a..000000000 Binary files a/docs/assets/form-builder-demo-3.png and /dev/null differ diff --git a/docs/assets/form-builder-demo.png b/docs/assets/form-builder-demo.png deleted file mode 100644 index 6065a6efd..000000000 Binary files a/docs/assets/form-builder-demo.png and /dev/null differ diff --git a/docs/assets/openapi-demo.png b/docs/assets/openapi-demo.png deleted file mode 100644 index 5992c7b07..000000000 Binary files a/docs/assets/openapi-demo.png and /dev/null differ diff --git a/docs/assets/product-proof/README.md b/docs/assets/product-proof/README.md new file mode 100644 index 000000000..b0991f79b --- /dev/null +++ b/docs/assets/product-proof/README.md @@ -0,0 +1,77 @@ +# BTST product-proof asset kit + +This kit pairs short, causal claims with real screens from the repository's generated Next.js application. The only conceptual asset is the editable ownership diagram. No mock product UI or model output is fabricated. + +## Visual contract + +- Preserve the graphite BTST symbol and use the `BTST` name consistently. +- Use graphite, paper, fog, and restrained cobalt; product screenshots retain their real UI colors. +- Set presentation copy in Geist Sans and labels in Geist Mono when available. +- Keep reusable proof frames at 16:9. A narrow claim rail explains what the screenshot proves; the larger area remains authentic product evidence. +- Annotate only a causal connection or visible outcome. Do not add invented metrics, version badges, customer logos, or testimonial claims. + +Product UI is captured deterministically in its dark theme. The raster proof frames are self-contained and do not change with the host color scheme; the ownership SVG intentionally uses a fixed light canvas. Whenever an asset or its frame changes, verify the README and docs embedding in both host themes so borders, captions, and surrounding contrast remain clear. + +## Capture recipe + +The committed outputs are deterministic captures of a freshly generated app. The recipe uses Node 22, the repository-pinned `pnpm@10.17.1`, Playwright Chromium, a 1440×900 viewport at DPR 1, UTC, `en-US`, dark theme, and reduced motion. Sharp frames each raw screenshot as a 1600×900 WebP. + +The coherent fixture is [dogfood-data.json](../../../e2e/product-proof/dogfood-data.json). It contains no personal data and describes one release-evidence story across Blog, Comments, Form Builder, UI Builder, Media, OpenAPI, Route Docs, and AI Chat. The capture workflow seeds Blog posts, an approved Comment, a published UI Builder page, and one exact-name Media asset through the registered plugin's direct multipart upload route. It removes that Media asset before and after every run, verifies the stored record, filters the generated library route, and requires exactly one visible asset card. Form Builder remains an unsaved interaction; OpenAPI and Route Docs are generated from the registered backend and client stacks. AI Chat requires both an empty authenticated-history API result and the visible empty-sidebar state before it types—but does not send—the fixture prompt. + +```bash +# From the repository root, with Node 22 active +corepack pnpm install --frozen-lockfile +corepack pnpm --filter e2e exec playwright install chromium +corepack pnpm build +SHADCN_VERSION=4.19.1 bash scripts/codegen/setup-nextjs.sh + +# In one terminal +PRODUCT_PROOF_BASE_URL=http://localhost:3006 \ + corepack pnpm -F nextjs exec next dev -p 3006 + +# In another terminal +PRODUCT_PROOF_BASE_URL=http://localhost:3006 \ + corepack pnpm --filter e2e exec node product-proof/capture.mjs + +node scripts/product-proof/check-assets.mjs \ + docs/assets/product-proof/manifest.json +bash scripts/codegen/cleanup.sh nextjs +``` + +The setup script defaults `SHADCN_VERSION` to `4.19.1`; change it only as an intentional toolchain update followed by a full recapture. The capture script removes and recreates only its known Blog, Comments, UI Builder, and Media fixtures, waits for fonts and network idle, disables motion and dev overlays, rejects stale naming or personal fixture text, captures the real routes, and writes static outputs. Run it twice from a fresh generated app and compare SHA-256 hashes when changing the workflow itself. + +Deliverable filenames are stable public references: keep a filename when refreshing the same proof, and rename it only when the evidence meaning changes, updating the manifest and every consumer atomically. `manifest.version` is the manifest/checker schema revision. Increment `manifest.assetRevision` whenever capture copy, source data, layout, or output pixels change. + +## Aperture and truth boundaries + +| Asset | Real state captured | Claim boundary | +| --- | --- | --- | +| README hero | Focused Blog route | Category promise plus one authentic, legible product result | +| Blog | Published Blog route | Canonical full-stack slice | +| Form Builder | Unsaved editor with three fields and live preview | Rich interactive UI; no submission claim | +| UI Builder | Persisted published page in editor | CMS-backed page composition | +| OpenAPI | Live Scalar reference route | Backend-only plugin with generated reference UI | +| AI Chat | Authenticated empty shell with typed prompt | No model response or tool result claimed | +| Code → result | Current Blog backend and client registrations plus published route | Causal registration-to-outcome pattern | + +The code excerpt is derived from the generated app's current [backend registration](../../../scripts/codegen/files/nextjs/lib/stack.ts) and [client registration](../../../scripts/codegen/files/nextjs/lib/stack-client.tsx). Capture fails with a source-specific drift error if either critical registration changes. It documents the registrations in the captured all-plugin dogfood app; no separate scaffold is implied. + +Every representative frame fits the entire 1440×900 product screen into a 16:9 aperture with `contain`; navigation, sidebar, and controls remain present rather than being cover-cropped. The README hero uses one intentional, full-width close crop of the real Blog route so its essential promise and proof remain legible at a roughly 358 px GitHub mobile width. Matte space may be added around a representative screen, but no claimed workflow or explanatory copy may be clipped. Rails and README copy must be completely visible at 1600×900 and remain readable at repository width. + +## Budgets and accessibility + +`manifest.json` is the source of truth for dimensions, individual byte budgets, the 1.8 MB whole-kit ceiling, alt text, captions, textual sources, prohibited copy, narrowly scoped source exceptions, and the kit/schema revisions. The checker reads SVG and WebP dimensions without relying on a globally installed image tool and scans every declared textual source case-insensitively. Source exceptions cover only exact-case lowercase npm package tokens and the exact canonical website URL; uppercase legacy lockups remain prohibited even inside an otherwise excepted source. + +Use the manifest's `alt` value when embedding an asset. Place its `caption` immediately below the image when the surrounding copy does not already state the same evidence. Decorative images must be explicitly marked and use empty alt text; this kit currently has no decorative deliverables. + +The exact project-owned symbol used in the README hero is retained as the editable [SVG source](source/btst-symbol.svg). + +## Verification record + +2026-09-01, Node 22 and `pnpm@10.17.1`: + +- Two consecutive capture runs produced identical SHA-256 hashes for all eight deliverables after the final copy and source-derivation changes. +- The asset checker passed at 206,291 bytes total against the 1.8 MB ceiling; all ten contract tests passed, including backend/client drift failures and scoped prohibited-copy exceptions. +- The generated app persisted one exact-name Media upload through the registered direct-upload route, displayed exactly one matching library card, and removed the database record and local file after each run. +- The GitHub Markdown API rendered the stable README hero reference in GFM mode. The pushed branch README was also checked at a 358 px browser viewport: the category promise and authentic Blog result remained legible without horizontal overflow. +- A production docs build rendered the corrected AI Chat proof at desktop and 390×844 mobile widths in both light and dark host themes. The image reported its complete 1600×900 natural dimensions, stayed within the content column, and produced no framework error overlay. diff --git a/docs/assets/product-proof/ai-chat-proof.webp b/docs/assets/product-proof/ai-chat-proof.webp new file mode 100644 index 000000000..94a999e00 Binary files /dev/null and b/docs/assets/product-proof/ai-chat-proof.webp differ diff --git a/docs/assets/product-proof/blog-proof.webp b/docs/assets/product-proof/blog-proof.webp new file mode 100644 index 000000000..c1e3769f4 Binary files /dev/null and b/docs/assets/product-proof/blog-proof.webp differ diff --git a/docs/assets/product-proof/code-to-result.webp b/docs/assets/product-proof/code-to-result.webp new file mode 100644 index 000000000..df0e96ba1 Binary files /dev/null and b/docs/assets/product-proof/code-to-result.webp differ diff --git a/docs/assets/product-proof/form-builder-proof.webp b/docs/assets/product-proof/form-builder-proof.webp new file mode 100644 index 000000000..83a2d0cc2 Binary files /dev/null and b/docs/assets/product-proof/form-builder-proof.webp differ diff --git a/docs/assets/product-proof/manifest.json b/docs/assets/product-proof/manifest.json new file mode 100644 index 000000000..02aa53ac0 --- /dev/null +++ b/docs/assets/product-proof/manifest.json @@ -0,0 +1,139 @@ +{ + "version": 1, + "assetRevision": 3, + "kitMaxBytes": 1800000, + "forbiddenText": [ + "Better Stack", + "Better-Stack", + "Sarah", + "grandmother", + "$50,000", + "$200,000", + "@olliethedev", + "@BTST/STACK" + ], + "textSources": [ + "../../../e2e/product-proof/capture.mjs", + "../../../e2e/product-proof/dogfood-data.json", + "../../../e2e/product-proof/registration-contract.mjs", + "../../../scripts/codegen/files/nextjs/lib/stack.ts", + "../../../scripts/codegen/files/nextjs/lib/stack-client.tsx", + "../../../scripts/codegen/files/nextjs/components/navbar.tsx", + "../../../packages/cli/src/templates/nextjs/components/navbar.tsx.hbs", + "../../../packages/cli/src/templates/react-router/components/navbar.tsx.hbs", + "../../../packages/cli/src/templates/tanstack/components/navbar.tsx.hbs", + "ownership-diagram.svg", + "source/btst-symbol.svg" + ], + "textSourceAllowlist": { + "../../../scripts/codegen/files/nextjs/lib/stack.ts": [ + { + "term": "@BTST/STACK", + "context": "lowercaseNpmPackageToken", + "token": "@btst/stack" + }, + { + "term": "Better-Stack", + "context": "canonicalWebsiteUrl", + "url": "https://www.better-stack.ai" + } + ], + "../../../scripts/codegen/files/nextjs/lib/stack-client.tsx": [ + { + "term": "@BTST/STACK", + "context": "lowercaseNpmPackageToken", + "token": "@btst/stack" + } + ] + }, + "assets": [ + { + "file": "readme-hero.webp", + "format": "webp", + "width": 1600, + "height": 900, + "maxBytes": 350000, + "alt": "BTST promise above a focused view of the real Blog route from the generated application.", + "caption": "Add complete capabilities while keeping the React application, shown with an authentic Blog result.", + "source": "../../../e2e/product-proof/capture.mjs", + "decorative": false + }, + { + "file": "ownership-diagram.svg", + "format": "svg", + "width": 1600, + "height": 900, + "maxBytes": 80000, + "alt": "Diagram separating the application and operating boundaries a team owns from the BTST runtimes and plugins running inside it.", + "caption": "BTST composes client and backend plugins inside the application; the app shell, ejected views, database, deployment, and external services remain yours.", + "source": "ownership-diagram.svg", + "decorative": false + }, + { + "file": "blog-proof.webp", + "format": "webp", + "width": 1600, + "height": 900, + "maxBytes": 240000, + "alt": "Real BTST Blog page with three published product-update posts in the generated Next.js application.", + "caption": "Blog is the canonical full-stack proof: one plugin supplies routes, backend behavior, client UI, and a visible published result.", + "source": "../../../e2e/product-proof/capture.mjs", + "decorative": false + }, + { + "file": "form-builder-proof.webp", + "format": "webp", + "width": 1600, + "height": 900, + "maxBytes": 280000, + "alt": "Real BTST Form Builder with Email, Text Area, and Select fields on the canvas and in the live form preview.", + "caption": "The shipped Form Builder provides a drag-and-drop workflow with a live form preview.", + "source": "../../../e2e/product-proof/capture.mjs", + "decorative": false + }, + { + "file": "ui-builder-proof.webp", + "format": "webp", + "width": 1600, + "height": 900, + "maxBytes": 280000, + "alt": "Real BTST UI Builder editing a release-evidence page with layers, canvas, status, and component properties.", + "caption": "UI Builder composes a published page against the same application runtime and deployment boundary.", + "source": "../../../e2e/product-proof/capture.mjs", + "decorative": false + }, + { + "file": "openapi-proof.webp", + "format": "webp", + "width": 1600, + "height": 900, + "maxBytes": 250000, + "alt": "Real Scalar API reference generated by the backend-only BTST OpenAPI plugin.", + "caption": "OpenAPI shows that a BTST plugin may add a focused backend capability without a matching client plugin.", + "source": "../../../e2e/product-proof/capture.mjs", + "decorative": false + }, + { + "file": "ai-chat-proof.webp", + "format": "webp", + "width": 1600, + "height": 900, + "maxBytes": 220000, + "alt": "Real authenticated BTST AI Chat shell with an empty conversation-history sidebar, attachment control, and a product-context prompt ready to send.", + "caption": "The capture proves the shipped chat shell and prompt boundary; it deliberately does not fabricate a model response.", + "source": "../../../e2e/product-proof/capture.mjs", + "decorative": false + }, + { + "file": "code-to-result.webp", + "format": "webp", + "width": 1600, + "height": 900, + "maxBytes": 260000, + "alt": "The current Blog backend and client registrations point to the real published Blog route they produce.", + "caption": "The code-to-result pattern connects the registered Blog backend and client halves with their visible product outcome.", + "source": "../../../e2e/product-proof/capture.mjs", + "decorative": false + } + ] +} diff --git a/docs/assets/product-proof/openapi-proof.webp b/docs/assets/product-proof/openapi-proof.webp new file mode 100644 index 000000000..146f1cee1 Binary files /dev/null and b/docs/assets/product-proof/openapi-proof.webp differ diff --git a/docs/assets/product-proof/ownership-diagram.svg b/docs/assets/product-proof/ownership-diagram.svg new file mode 100644 index 000000000..bdbf11b33 --- /dev/null +++ b/docs/assets/product-proof/ownership-diagram.svg @@ -0,0 +1,65 @@ + + BTST ownership boundaries + A React application keeps its app shell and ejected view code. A framework adapter connects it to the BTST client and backend runtimes. Those runtimes compose client, backend, and one-sided plugins while the team keeps its database, deployment, and external services. + + + + + + + + OWNERSHIP MAP + BTST runs inside the application you operate. + The blue boundary is BTST. Every graphite boundary stays under your control. + + + YOUR APPLICATION + + React app shell + Navigation · auth · product UI + + Ejected view code + Edit, fork, or replace locally + + Framework adapter + Next.js · React Router · TanStack + + + BTST PACKAGES IN YOUR PROCESS + + Client runtime + Routes · loaders · hooks · SSR-aware pages + + Client plugins + Blog UI · Forms UI + Route Docs · UI Builder + + Backend plugins + Schemas · handlers + OpenAPI · lifecycle hooks + + Backend runtime + Request context · authorization · data access + ONE-SIDED PLUGINS STAY ONE-SIDED + + + YOUR OPERATING BOUNDARIES + + Database + Choose and operate the adapter + + Deployment + Host the application where you choose + + External services + Models · storage · email · analytics + + + + + + + GRAPHITE = YOU OPERATE IT + + COBALT = BTST COMPOSES HERE + diff --git a/docs/assets/product-proof/readme-hero.webp b/docs/assets/product-proof/readme-hero.webp new file mode 100644 index 000000000..eae6beae9 Binary files /dev/null and b/docs/assets/product-proof/readme-hero.webp differ diff --git a/docs/assets/product-proof/source/btst-symbol.svg b/docs/assets/product-proof/source/btst-symbol.svg new file mode 100644 index 000000000..83ea5a423 --- /dev/null +++ b/docs/assets/product-proof/source/btst-symbol.svg @@ -0,0 +1,8 @@ + + + BTST symbol + + + + + diff --git a/docs/assets/product-proof/ui-builder-proof.webp b/docs/assets/product-proof/ui-builder-proof.webp new file mode 100644 index 000000000..eaad4b2a3 Binary files /dev/null and b/docs/assets/product-proof/ui-builder-proof.webp differ diff --git a/docs/assets/ui-builder-demo-1.gif b/docs/assets/ui-builder-demo-1.gif deleted file mode 100644 index e34e38a7e..000000000 Binary files a/docs/assets/ui-builder-demo-1.gif and /dev/null differ diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index d287cb89d..908be7e76 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -5,7 +5,13 @@ description: Understand the architecture and key concepts behind BTST. Here's a high-level overview of how BTST works: -![Architecture Overview](../../assets/architecture.png) +![Diagram separating the application and operating boundaries a team owns from the BTST runtimes and plugins running inside it.](../../assets/product-proof/ownership-diagram.svg) + +BTST composes client and backend plugins inside your application. Your app shell, ejected views, database, deployment, and external services remain yours. + +![The current Blog backend and client registrations point to the real published Blog route they produce.](../../assets/product-proof/code-to-result.webp) + +Register Blog with the backend and client stacks, then inspect the resulting route at `/pages/blog`. ## Server Side diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index 5f4f8e269..e2f9b0ded 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -7,18 +7,16 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import Image from "next/image"; -import chatDemo from "../../../assets/chat-demo.png"; -import chatDemo1 from "../../../assets/chat-demo-1.png"; +import aiChatProof from "../../../assets/product-proof/ai-chat-proof.webp"; -
- - AI Chat Plugin Demo - - - AI Chat Plugin Demo - Conversation +
+ + Real authenticated BTST AI Chat shell with an empty conversation-history sidebar, attachment control, and a product-context prompt ready to send.
+This capture proves the shipped chat shell and prompt boundary. It deliberately does not fabricate a model response. + [View interactive demo →](https://www.better-stack.ai/playground?plugins=ai-chat&view=preview) ## Installation diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index a76a80ecb..79d592a57 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -7,26 +7,16 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import Image from "next/image"; -import blogDemo from "../../../assets/blog-demo.png"; -import blogDemo1 from "../../../assets/blog-demo-1.png"; -import blogDemo2 from "../../../assets/blog-demo-2.png"; -import blogDemo3 from "../../../assets/blog-demo-3.png"; - -
- - Blog Plugin Demo - Home - - - Blog Plugin Demo - Post - - - Blog Plugin Demo - Editor - - - Blog Plugin Demo - Drafts +import blogProof from "../../../assets/product-proof/blog-proof.webp"; + +
+ + Real BTST Blog page with three published product-update posts in the generated Next.js application.
+Blog is the canonical full-stack proof: routes, backend behavior, client UI, and a visible published result arrive as one plugin. + [View interactive demo →](https://www.better-stack.ai/playground?plugins=blog&view=preview) ## Installation diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index 7b66a1aea..7c60dfd76 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -7,23 +7,11 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import Image from "next/image"; -import formBuilderDemo from "../../../assets/form-builder-demo.png"; -import formBuilderDemo1 from "../../../assets/form-builder-demo-1.png"; -import formBuilderDemo2 from "../../../assets/form-builder-demo-2.png"; -import formBuilderDemo3 from "../../../assets/form-builder-demo-3.png"; - -
- - Form Builder Plugin Demo - Forms List - - - Form Builder Plugin Demo - Builder - - - Form Builder Plugin Demo - Preview - - - Form Builder Plugin Demo - Submissions +import formBuilderProof from "../../../assets/product-proof/form-builder-proof.webp"; + +
+ + Real BTST Form Builder with Email, Text Area, and Select fields on the canvas and in the live form preview.
diff --git a/docs/content/docs/plugins/open-api.mdx b/docs/content/docs/plugins/open-api.mdx index d9054f2cb..54e4103c9 100644 --- a/docs/content/docs/plugins/open-api.mdx +++ b/docs/content/docs/plugins/open-api.mdx @@ -7,11 +7,11 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import Image from "next/image"; -import openApiDemo from "../../../assets/openapi-demo.png"; +import openApiProof from "../../../assets/product-proof/openapi-proof.webp"; -
- - OpenAPI Plugin Demo - Interactive Scalar UI +
+ + Real Scalar API reference generated by the backend-only BTST OpenAPI plugin.
diff --git a/docs/content/docs/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx index c936f27e5..7e8bed800 100644 --- a/docs/content/docs/plugins/ui-builder.mdx +++ b/docs/content/docs/plugins/ui-builder.mdx @@ -8,11 +8,11 @@ import { Callout } from "fumadocs-ui/components/callout"; import { BookOpen } from "lucide-react"; import Image from "next/image"; -import uiBuilderDemo from "../../../assets/ui-builder-demo-1.gif"; +import uiBuilderProof from "../../../assets/product-proof/ui-builder-proof.webp";
- - UI Builder Plugin Demo + + Real BTST UI Builder editing a release-evidence page with layers, canvas, status, and component properties.
diff --git a/e2e/package.json b/e2e/package.json index ca6d4bea8..088363d4e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,6 +14,7 @@ "devDependencies": { "@playwright/test": "^1.48.2", "dotenv": "^16.4.5", + "sharp": "0.34.5", "tsx": "^4.20.3" } } diff --git a/e2e/product-proof/capture.mjs b/e2e/product-proof/capture.mjs new file mode 100644 index 000000000..041d8ceaf --- /dev/null +++ b/e2e/product-proof/capture.mjs @@ -0,0 +1,637 @@ +#!/usr/bin/env node + +import { chromium } from "@playwright/test"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import { loadBlogRegistrationProof } from "./registration-contract.mjs"; + +const sourceRoot = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(sourceRoot, "../.."); +const outputRoot = resolve(repoRoot, "docs/assets/product-proof"); +const baseURL = process.env.PRODUCT_PROOF_BASE_URL ?? "http://localhost:3006"; +const blogRegistrationProof = await loadBlogRegistrationProof(repoRoot); +const seed = JSON.parse( + await readFile(join(sourceRoot, "dogfood-data.json"), "utf8"), +); +const manifest = JSON.parse( + await readFile(join(outputRoot, "manifest.json"), "utf8"), +); +const captureRoot = await mkdtemp(join(tmpdir(), "btst-product-proof-")); + +const palette = { + ink: "#0B0D0E", + graphite: "#191B1F", + paper: "#F7F7F5", + fog: "#E7E9EC", + muted: "#5E6670", + cobalt: "#2563EB", + white: "#FFFFFF", +}; + +const forbiddenText = manifest.forbiddenText; + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function textBlock(lines, x, y, size, options = {}) { + const { + color = palette.white, + weight = 600, + lineHeight = 1.15, + mono = false, + } = options; + return `${lines + .map( + (line, index) => + `${escapeXml(line)}`, + ) + .join("")}`; +} + +async function roundedScreenshot(input, width, height) { + const mask = Buffer.from( + ``, + ); + return sharp(input) + .resize(width, height, { + fit: "contain", + position: "center", + background: palette.ink, + }) + .composite([{ input: mask, blend: "dest-in" }]) + .png() + .toBuffer(); +} + +async function writeWebP(file, base, composites, quality = 72) { + await sharp(base) + .composite(composites) + .webp({ quality, effort: 6, smartSubsample: true }) + .toFile(join(outputRoot, file)); +} + +function frameSvg({ eyebrow, title, body, result, label }) { + return Buffer.from(` + + + + ${escapeXml(eyebrow.toUpperCase())} + ${textBlock(title, 64, 170, 40, { weight: 720, lineHeight: 1.08 })} + ${textBlock(body, 64, 350, 21, { color: "#C8CDD3", weight: 420, lineHeight: 1.45 })} + + + VISIBLE RESULT + ${escapeXml(result)} + ${escapeXml(label)} + + `); +} + +async function proofFrame(file, screenshot, copy) { + const product = await roundedScreenshot(screenshot, 1120, 700); + await writeWebP(file, frameSvg(copy), [ + { input: product, left: 440, top: 100 }, + { + input: Buffer.from( + ``, + ), + left: 440, + top: 100, + }, + ]); +} + +async function sanitizePage(page, { hideSuggestions = false } = {}) { + await page.addStyleTag({ + content: ` + *, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; caret-color: transparent !important; } + nextjs-portal, [data-nextjs-toast], [data-next-badge-root], .tsqd-open-btn-container { display: none !important; } + [data-testid="chat-widget"] { display: none !important; } + `, + }); + await page.evaluate( + ({ hideSuggestions }) => { + for (const button of document.querySelectorAll("button")) { + const label = + button.getAttribute("aria-label") ?? button.textContent ?? ""; + if ( + /Open (Next\.js Dev Tools|Tanstack query devtools|chat)|issues overlay|Collapse issues badge/i.test( + label, + ) + ) { + button.style.visibility = "hidden"; + } + } + if (hideSuggestions) { + const suggestions = document.querySelectorAll( + '[data-testid="chat-interface"] .flex.flex-wrap.justify-center.gap-2 > button', + ); + if (suggestions.length === 0) { + throw new Error("AI Chat suggestion controls were not found"); + } + for (const suggestion of suggestions) suggestion.style.display = "none"; + } + }, + { hideSuggestions }, + ); + await page.evaluate(() => document.fonts.ready); +} + +async function assertNoForbiddenText(page, asset) { + const body = (await page.locator("body").innerText()).toLowerCase(); + for (const forbidden of forbiddenText) { + if (body.includes(forbidden.toLowerCase())) { + throw new Error( + `${asset}: page contains forbidden text ${JSON.stringify(forbidden)}`, + ); + } + } +} + +async function visit(page, path, options = {}) { + const errors = []; + const onConsole = (message) => { + if (message.type() === "error") errors.push(message.text()); + }; + page.on("console", onConsole); + const response = await page.goto(`${baseURL}${path}`, { + waitUntil: "networkidle", + }); + if (!response?.ok()) + throw new Error(`${path}: HTTP ${response?.status() ?? "unknown"}`); + await sanitizePage(page, options); + await assertNoForbiddenText(page, path); + page.off("console", onConsole); + const meaningfulErrors = errors.filter( + (error) => + !error.includes("favicon") && !error.includes("Failed to load resource"), + ); + if (meaningfulErrors.length > 0) { + throw new Error(`${path}: console errors:\n${meaningfulErrors.join("\n")}`); + } +} + +async function screenshot(page, file) { + const path = join(captureRoot, file); + await page.screenshot({ path, animations: "disabled" }); + return path; +} + +async function jsonRequest(request, method, path, data) { + const response = await request.fetch(`${baseURL}${path}`, { + method, + headers: { + cookie: `btst.example_session=mock-session-${seed.identity}`, + ...(data ? { "content-type": "application/json" } : {}), + }, + ...(data ? { data } : {}), + }); + if (!response.ok()) { + throw new Error( + `${method} ${path}: ${response.status()} ${await response.text()}`, + ); + } + return response; +} + +async function assertEmptyChatHistory(request) { + const response = await jsonRequest( + request, + "GET", + "/api/data/chat/conversations", + ); + const conversations = await response.json(); + if (!Array.isArray(conversations) || conversations.length !== 0) { + throw new Error( + `AI Chat proof requires an empty authenticated history; found ${Array.isArray(conversations) ? conversations.length : "a non-array response"}`, + ); + } +} + +async function seedBlog(request) { + const response = await jsonRequest( + request, + "GET", + "/api/data/posts?limit=100&offset=0", + ); + const current = await response.json(); + for (const post of current.items ?? []) { + if (seed.posts.some((candidate) => candidate.slug === post.slug)) { + await jsonRequest(request, "DELETE", `/api/data/posts/${post.id}`); + } + } + for (const post of seed.posts) + await jsonRequest(request, "POST", "/api/data/posts", post); +} + +async function seedComments(request) { + for (const comment of seed.comments) { + for (const status of ["pending", "approved", "spam"]) { + const params = new URLSearchParams({ + resourceId: comment.resourceId, + resourceType: comment.resourceType, + status, + limit: "100", + offset: "0", + }); + const response = await jsonRequest( + request, + "GET", + `/api/data/comments?${params}`, + ); + const current = await response.json(); + for (const candidate of current.items ?? []) { + if (candidate.body === comment.body) { + await jsonRequest( + request, + "DELETE", + `/api/data/comments/${candidate.id}`, + ); + } + } + } + const created = await jsonRequest(request, "POST", "/api/data/comments", { + resourceId: comment.resourceId, + resourceType: comment.resourceType, + parentId: null, + body: comment.body, + }); + const createdComment = await created.json(); + if (comment.status !== createdComment.status) { + await jsonRequest( + request, + "PATCH", + `/api/data/comments/${createdComment.id}/status`, + { status: comment.status }, + ); + } + const countParams = new URLSearchParams({ + resourceId: comment.resourceId, + resourceType: comment.resourceType, + status: comment.status, + }); + const countResponse = await jsonRequest( + request, + "GET", + `/api/data/comments/count?${countParams}`, + ); + const { count } = await countResponse.json(); + if (count < 1) { + throw new Error( + `Comments fixture was not persisted for ${comment.resourceId}`, + ); + } + } +} + +async function cleanupMedia(request) { + const params = new URLSearchParams({ + query: seed.media.uploadName, + limit: "100", + offset: "0", + }); + const currentResponse = await jsonRequest( + request, + "GET", + `/api/data/media/assets?${params}`, + ); + const current = await currentResponse.json(); + for (const asset of current.items ?? []) { + if (asset.originalName === seed.media.uploadName) { + await jsonRequest( + request, + "DELETE", + `/api/data/media/assets/${asset.id}`, + ); + } + } +} + +async function seedMedia(request) { + await cleanupMedia(request); + + const fixtureBuffer = await readFile(resolve(sourceRoot, seed.media.fixture)); + const uploadedResponse = await request.post( + `${baseURL}/api/data/media/upload`, + { + headers: { + cookie: `btst.example_session=mock-session-${seed.identity}`, + }, + multipart: { + file: { + name: seed.media.uploadName, + mimeType: seed.media.mimeType, + buffer: fixtureBuffer, + }, + }, + }, + ); + if (!uploadedResponse.ok()) { + throw new Error( + `POST /api/data/media/upload: ${uploadedResponse.status()} ${await uploadedResponse.text()}`, + ); + } + const uploaded = await uploadedResponse.json(); + if ( + uploaded.originalName !== seed.media.uploadName || + uploaded.mimeType !== seed.media.mimeType || + uploaded.size !== fixtureBuffer.byteLength || + typeof uploaded.url !== "string" || + !uploaded.url.startsWith("/uploads/") + ) { + throw new Error( + `Media upload response did not match the deterministic fixture: ${JSON.stringify(uploaded)}`, + ); + } + await jsonRequest(request, "PATCH", `/api/data/media/assets/${uploaded.id}`, { + alt: seed.media.alt, + }); + + const persistedResponse = await jsonRequest( + request, + "GET", + `/api/data/media/assets?${new URLSearchParams({ query: seed.media.uploadName, limit: "100", offset: "0" })}`, + ); + const persisted = await persistedResponse.json(); + const matchingAssets = (persisted.items ?? []).filter( + (asset) => + asset.id === uploaded.id && + asset.originalName === seed.media.uploadName && + asset.mimeType === seed.media.mimeType && + asset.size === fixtureBuffer.byteLength && + asset.alt === seed.media.alt, + ); + if (matchingAssets.length !== 1) { + throw new Error( + `Media fixture expected one persisted ${seed.media.uploadName}, found ${matchingAssets.length}`, + ); + } +} + +async function seedUiBuilder(request) { + const response = await jsonRequest( + request, + "GET", + "/api/data/content/ui-builder-page?limit=100&offset=0", + ); + const current = await response.json(); + for (const page of current.items ?? []) { + if (page.slug === seed.uiBuilder.slug) { + await jsonRequest( + request, + "DELETE", + `/api/data/content/ui-builder-page/${page.id}`, + ); + } + } + const created = await jsonRequest( + request, + "POST", + "/api/data/content/ui-builder-page", + { + slug: seed.uiBuilder.slug, + data: { + layers: JSON.stringify(seed.uiBuilder.layers), + variables: "[]", + status: "published", + }, + }, + ); + return created.json(); +} + +function heroSvg() { + return Buffer.from(` + + + BTST / COMPOSABLE CAPABILITIES FOR REACT + ${textBlock(["Add complete capabilities.", "Keep your app."], 72, 205, 72, { weight: 740, lineHeight: 1.04 })} + Install a complete capability. + + + REAL /pages/blog + + `); +} + +async function readmeHero(blog) { + const symbol = await sharp(join(outputRoot, "source/btst-symbol.svg")) + .resize(44, 44) + .png() + .toBuffer(); + const proofMask = Buffer.from( + '', + ); + const blogProof = await sharp(blog) + .extract({ left: 0, top: 66, width: 1440, height: 340 }) + .resize(1416, 334, { fit: "fill" }) + .composite([{ input: proofMask, blend: "dest-in" }]) + .png() + .toBuffer(); + await writeWebP("readme-hero.webp", heroSvg(), [ + { input: symbol, left: 72, top: 58 }, + { input: blogProof, left: 92, top: 474 }, + ]); +} + +async function codeToResult(blog, registrationProof) { + const result = await roundedScreenshot(blog, 760, 720); + const base = + Buffer.from(` + + CODE → RESULT + ${textBlock(["One feature, registered", "on both sides."], 70, 160, 46, { color: palette.ink, weight: 720, lineHeight: 1.1 })} + + BACKEND / lib/stack.ts + ${textBlock(registrationProof.backendExcerpt, 110, 395, 17, { + color: "#DDE3EA", + weight: 500, + lineHeight: 1.45, + mono: true, + })} + CLIENT / lib/stack-client.tsx + ${textBlock(registrationProof.clientExcerpt, 110, 535, 14, { + color: "#DDE3EA", + weight: 500, + lineHeight: 1.3, + mono: true, + })} + ROUTE /pages/blog + + + + `); + await writeWebP("code-to-result.webp", base, [ + { input: result, left: 826, top: 90 }, + ]); +} + +async function main() { + await mkdir(outputRoot, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + colorScheme: "dark", + reducedMotion: "reduce", + locale: "en-US", + timezoneId: "UTC", + }); + await context.addCookies([ + { + name: "btst.example_session", + value: `mock-session-${seed.identity}`, + url: baseURL, + httpOnly: true, + sameSite: "Lax", + }, + ]); + await context.addInitScript(() => localStorage.setItem("theme", "dark")); + const page = await context.newPage(); + try { + await seedBlog(context.request); + await seedComments(context.request); + await visit(page, "/pages/blog"); + await page.getByRole("heading", { name: "Blog Posts" }).waitFor(); + const blog = await screenshot(page, "blog.png"); + + await visit(page, "/pages/forms/new"); + await page.getByPlaceholder("Enter form name").fill(seed.form.name); + await page.getByPlaceholder("enter-form-slug").fill(seed.form.slug); + const canvas = page.getByTestId("canvas-drop-zone"); + await page + .getByRole("button", { name: "Email", exact: true }) + .dragTo(canvas); + await page + .getByRole("button", { name: "Text Area", exact: true }) + .dragTo(page.getByTestId("form-builder-canvas")); + await page + .getByRole("button", { name: "Select", exact: true }) + .dragTo(page.getByTestId("form-builder-canvas")); + const form = await screenshot(page, "form-builder.png"); + + const uiBuilderPage = await seedUiBuilder(context.request); + await visit(page, `/pages/ui-builder/${uiBuilderPage.id}/edit`); + await page.getByRole("heading", { name: "Component Properties" }).waitFor(); + const uiBuilder = await screenshot(page, "ui-builder.png"); + + await seedMedia(context.request); + await visit(page, seed.media.libraryPath); + await page.getByPlaceholder(seed.media.expectedControl).waitFor(); + await page + .getByPlaceholder(seed.media.expectedControl) + .fill(seed.media.uploadName); + const mediaCard = page + .locator('[data-testid="media-asset-item"]') + .filter({ hasText: seed.media.uploadName }); + await mediaCard.waitFor(); + if ((await mediaCard.count()) !== 1) { + throw new Error( + `Media library expected one visible ${seed.media.uploadName} card`, + ); + } + + await visit(page, seed.routeDocs.pagePath); + await page + .getByText(seed.routeDocs.expectedTitle, { exact: false }) + .first() + .waitFor(); + + await visit(page, seed.openApi.referencePath); + await page + .getByText(seed.openApi.expectedTitle, { exact: false }) + .first() + .waitFor({ timeout: 30_000 }); + const openapi = await screenshot(page, "openapi.png"); + + await assertEmptyChatHistory(context.request); + await visit(page, "/pages/chat", { hideSuggestions: true }); + await page + .getByTestId("chat-sidebar") + .getByText("No conversations yet", { exact: true }) + .waitFor(); + await page.getByPlaceholder("Type a message...").fill(seed.chatPrompt); + await assertNoForbiddenText(page, "ai-chat-proof"); + const chat = await screenshot(page, "ai-chat.png"); + + await proofFrame("blog-proof.webp", blog, { + eyebrow: "Canonical full-stack proof", + title: ["Blog ships", "a complete", "slice."], + body: [ + "Routes, backend behavior,", + "client UI, and product output", + "arrive as one plugin.", + ], + result: "Published product route", + label: "BLOG / AUTHENTIC GENERATED APP", + }); + await proofFrame("form-builder-proof.webp", form, { + eyebrow: "Rich interaction proof", + title: ["Build forms", "inside your app."], + body: [ + "A real drag-and-drop builder", + "and live preview—not a", + "decorative mockup.", + ], + result: "Editable form + live preview", + label: "FORM BUILDER / AUTHENTIC GENERATED APP", + }); + await proofFrame("ui-builder-proof.webp", uiBuilder, { + eyebrow: "Complex UI proof", + title: ["Compose pages.", "Keep the code."], + body: [ + "The builder works against the", + "same application runtime and", + "deployment boundary.", + ], + result: "Published page composition", + label: "UI BUILDER / AUTHENTIC GENERATED APP", + }); + await proofFrame("openapi-proof.webp", openapi, { + eyebrow: "One-sided plugin proof", + title: ["OpenAPI needs", "no client half."], + body: [ + "Add a focused backend", + "capability without forcing", + "a matching product interface.", + ], + result: "Browsable API reference", + label: "OPENAPI / AUTHENTIC GENERATED APP", + }); + await proofFrame("ai-chat-proof.webp", chat, { + eyebrow: "Authenticated shell proof", + title: ["Authenticated.", "Prompt ready."], + body: [ + "This capture proves the", + "conversation shell and prompt", + "boundary—not a fabricated", + "reply.", + ], + result: "Prompt ready to send", + label: "AI CHAT / NO MODEL OUTPUT FABRICATED", + }); + await readmeHero(blog); + await codeToResult(blog, blogRegistrationProof); + } finally { + try { + await cleanupMedia(context.request); + } finally { + await browser.close(); + } + } + console.log(`Captured product proof to ${outputRoot}`); +} + +try { + await main(); +} finally { + await rm(captureRoot, { recursive: true, force: true }); +} diff --git a/e2e/product-proof/dogfood-data.json b/e2e/product-proof/dogfood-data.json new file mode 100644 index 000000000..c19967248 --- /dev/null +++ b/e2e/product-proof/dogfood-data.json @@ -0,0 +1,122 @@ +{ + "identity": "admin-product-proof", + "posts": [ + { + "title": "The architecture stays yours", + "content": "# Keep your boundaries\n\nBTST composes with your database, framework, and deployment.", + "excerpt": "What BTST adds—and which operating boundaries remain under your control.", + "slug": "architecture-stays-yours", + "published": true, + "publishedAt": "2026-08-06T12:00:00.000Z", + "image": "" + }, + { + "title": "A safer release workflow", + "content": "# Release evidence\n\nPreview the exact routes your team owns before publishing.", + "excerpt": "A repeatable path from plugin setup to a reviewable product screen.", + "slug": "safer-release-workflow", + "published": true, + "publishedAt": "2026-08-12T12:00:00.000Z", + "image": "" + }, + { + "title": "Shipping the plugin catalog", + "content": "# One surface, six capabilities\n\nThe team connected content, forms, comments, media, route docs, and OpenAPI without moving the application.", + "excerpt": "How the team added product capabilities while keeping the app, data, and deployment.", + "slug": "shipping-plugin-catalog", + "published": true, + "publishedAt": "2026-08-18T12:00:00.000Z", + "image": "" + } + ], + "form": { + "name": "Plugin evaluation", + "slug": "plugin-evaluation" + }, + "comments": [ + { + "resourceId": "shipping-plugin-catalog", + "resourceType": "blog-post", + "body": "The request context keeps this release discussion attached to the published route.", + "status": "approved" + } + ], + "media": { + "libraryPath": "/pages/media", + "fixture": "../fixtures/test-image.png", + "uploadName": "btst-product-proof-media.png", + "mimeType": "image/png", + "alt": "BTST release evidence fixture", + "expectedControl": "Search files…", + "description": "A checked-in image uploaded into the registered Media plugin and asserted visible in its generated library route.", + "provenance": "seeded-through-registered-media-upload-route" + }, + "openApi": { + "referencePath": "/api/data/reference", + "expectedTitle": "BTST Codegen API", + "provenance": "generated-from-registered-backend-stack" + }, + "routeDocs": { + "pagePath": "/pages/route-docs", + "expectedTitle": "Client Route Documentation", + "provenance": "generated-from-registered-client-stack" + }, + "uiBuilder": { + "slug": "release-evidence", + "layers": [ + { + "id": "root", + "type": "div", + "name": "Release page", + "props": { + "className": "p-8 space-y-5" + }, + "children": [ + { + "id": "eyebrow", + "type": "span", + "name": "Status", + "props": {}, + "children": "Release evidence" + }, + { + "id": "title", + "type": "h1", + "name": "Title", + "props": { + "className": "text-3xl font-bold" + }, + "children": "Every capability, still inside your app" + }, + { + "id": "body", + "type": "p", + "name": "Summary", + "props": { + "className": "text-lg text-muted-foreground" + }, + "children": "Blog, forms, comments, media, route docs, and OpenAPI share one runtime and one request context." + }, + { + "id": "cta", + "type": "Button", + "name": "Action", + "props": { + "variant": "default" + }, + "children": [ + { + "id": "cta-label", + "type": "span", + "name": "Action label", + "props": {}, + "children": "Review release" + } + ] + } + ] + } + ] + }, + "chatPrompt": "How do Blog and Comments share the same request context?" +} diff --git a/e2e/product-proof/registration-contract.mjs b/e2e/product-proof/registration-contract.mjs new file mode 100644 index 000000000..dd51f4483 --- /dev/null +++ b/e2e/product-proof/registration-contract.mjs @@ -0,0 +1,71 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const BACKEND_SOURCE_PATH = "scripts/codegen/files/nextjs/lib/stack.ts"; +const CLIENT_SOURCE_PATH = "scripts/codegen/files/nextjs/lib/stack-client.tsx"; + +function sourceLine(source, marker) { + const line = source + .split("\n") + .find((candidate) => candidate.includes(marker)); + return line?.trim(); +} + +function clientRegistrationExcerpt(source) { + const lines = source.split("\n"); + const start = lines.findIndex((line) => + line.includes("blog: blogClientPlugin({"), + ); + const defaultImage = lines.findIndex( + (line, index) => + index >= start && + line.includes("defaultImage: `${siteOrigin}/og-image.png`,"), + ); + if (start < 0 || defaultImage < start) return undefined; + const indentation = lines[start].match(/^\s*/)?.[0].length ?? 0; + const seoClose = defaultImage + 1; + const pluginClose = lines.findIndex( + (line, index) => + index > seoClose && + (line.match(/^\s*/)?.[0].length ?? -1) === indentation && + line.trim() === "}),", + ); + if (lines[seoClose]?.trim() !== "}," || pluginClose < 0) return undefined; + return [ + ...lines.slice(start, seoClose + 1).map((line) => line.slice(indentation)), + "\t// … hooks unchanged", + lines[pluginClose].slice(indentation), + ]; +} + +export function assertBlogRegistrationSources({ backendSource, clientSource }) { + const backendLine = sourceLine( + backendSource, + "blog: blogBackendPlugin({ hooks: blogHooks }),", + ); + if (!backendLine) { + throw new Error( + `Blog backend registration proof drifted from ${BACKEND_SOURCE_PATH}`, + ); + } + const clientLines = clientRegistrationExcerpt(clientSource); + if ( + !clientLines || + !clientLines.some((line) => line.includes('siteName: "BTST Blog"')) || + !clientLines.some((line) => line.includes('author: "BTST Team"')) + ) { + throw new Error( + `Blog client registration proof drifted from ${CLIENT_SOURCE_PATH}`, + ); + } + + return { backendExcerpt: [backendLine], clientExcerpt: clientLines }; +} + +export async function loadBlogRegistrationProof(repoRoot) { + const [backendSource, clientSource] = await Promise.all([ + readFile(resolve(repoRoot, BACKEND_SOURCE_PATH), "utf8"), + readFile(resolve(repoRoot, CLIENT_SOURCE_PATH), "utf8"), + ]); + return assertBlogRegistrationSources({ backendSource, clientSource }); +} diff --git a/package.json b/package.json index 894ed7fae..3964c4461 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ "format": "biome format . --write", "lint": "node scripts/check-canonical-dx.mjs && biome check .", "check:canonical-dx": "node scripts/check-canonical-dx.mjs", + "capture:product-proof": "corepack pnpm --filter e2e exec node product-proof/capture.mjs", + "check:product-proof": "node scripts/product-proof/check-assets.mjs docs/assets/product-proof/manifest.json", "lint:fix": "biome check . --fix --unsafe", "bump": "bumpp", "test": "turbo --filter \"./packages/*\" test", "test:packed-consumers": "vitest run scripts/packed-consumer/runner.test.mjs", + "test:product-proof": "node --test scripts/product-proof/check-assets.test.mjs", "smoke:packed-consumer": "node scripts/packed-consumer-smoke.mjs", "e2e:integration": "turbo --filter \"./e2e/*\" e2e:integration", "typecheck": "turbo --filter \"./packages/*\" typecheck", diff --git a/packages/cli/src/templates/nextjs/components/navbar.tsx.hbs b/packages/cli/src/templates/nextjs/components/navbar.tsx.hbs index 1150075ad..2fe7d397d 100644 --- a/packages/cli/src/templates/nextjs/components/navbar.tsx.hbs +++ b/packages/cli/src/templates/nextjs/components/navbar.tsx.hbs @@ -19,7 +19,7 @@ export function Navbar() {
- @BTST/STACK + BTST
diff --git a/packages/cli/src/templates/react-router/components/navbar.tsx.hbs b/packages/cli/src/templates/react-router/components/navbar.tsx.hbs index 02558a21a..74a906dd5 100644 --- a/packages/cli/src/templates/react-router/components/navbar.tsx.hbs +++ b/packages/cli/src/templates/react-router/components/navbar.tsx.hbs @@ -19,7 +19,7 @@ export function Navbar() {
- @BTST/STACK + BTST
diff --git a/packages/cli/src/templates/tanstack/components/navbar.tsx.hbs b/packages/cli/src/templates/tanstack/components/navbar.tsx.hbs index 2b91011ec..f18f05e74 100644 --- a/packages/cli/src/templates/tanstack/components/navbar.tsx.hbs +++ b/packages/cli/src/templates/tanstack/components/navbar.tsx.hbs @@ -19,7 +19,7 @@ export function Navbar() {
- @BTST/STACK + BTST
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c2b7aaa4..f116f7ef4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 + sharp: + specifier: 0.34.5 + version: 0.34.5 tsx: specifier: ^4.20.3 version: 4.21.0 diff --git a/scripts/codegen/files/nextjs/app/pages/client-layout.tsx b/scripts/codegen/files/nextjs/app/pages/client-layout.tsx index ec9d02f7b..45570c7c7 100644 --- a/scripts/codegen/files/nextjs/app/pages/client-layout.tsx +++ b/scripts/codegen/files/nextjs/app/pages/client-layout.tsx @@ -88,11 +88,11 @@ export function BtstPagesClientLayout({ aiChat: { uploadFile: uploadFileForChat, chatSuggestions: [ - "Hi, I'm Sarah, 34. I'm getting married next year and I just inherited $50,000 from my grandmother. I have no debt and about $30k in savings. I'm wondering if my current moderate-risk portfolio still makes sense.", - "Hi, I run a small import business and want to invest $200,000. The money came from overseas sales across multiple countries over the past few months. I'd like to move it into Canadian equities right away.", - "What information do I need to provide for a financial review?", - "I'm approaching retirement in the next few years — what should I be thinking about?", - "How is my risk tolerance assessed?", + "How do Blog and Comments share the same request context?", + "Which BTST plugins include both backend and client registrations?", + "What stays under my control after I install a plugin?", + "Show me the routes added by Form Builder.", + "How can I customize an ejected view?", ], }, cms: { diff --git a/scripts/codegen/files/nextjs/components/navbar.tsx b/scripts/codegen/files/nextjs/components/navbar.tsx index 4afd00cd2..712234641 100644 --- a/scripts/codegen/files/nextjs/components/navbar.tsx +++ b/scripts/codegen/files/nextjs/components/navbar.tsx @@ -19,7 +19,7 @@ export function Navbar() {
- @BTST/STACK + BTST
diff --git a/scripts/codegen/files/nextjs/lib/stack-client.tsx b/scripts/codegen/files/nextjs/lib/stack-client.tsx index d119745a9..34cf1e2d2 100644 --- a/scripts/codegen/files/nextjs/lib/stack-client.tsx +++ b/scripts/codegen/files/nextjs/lib/stack-client.tsx @@ -91,7 +91,6 @@ export const createAppClientStack = ( seo: { siteName: "BTST Blog", author: "BTST Team", - twitterHandle: "@olliethedev", locale: "en_US", defaultImage: `${siteOrigin}/og-image.png`, }, diff --git a/scripts/codegen/files/nextjs/next.config.ts b/scripts/codegen/files/nextjs/next.config.ts new file mode 100644 index 000000000..0f066129b --- /dev/null +++ b/scripts/codegen/files/nextjs/next.config.ts @@ -0,0 +1,25 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + env: { + // Expose whether an OpenAI key is set so the pages layout can show a banner. + NEXT_PUBLIC_HAS_OPENAI_KEY: process.env.OPENAI_API_KEY ? "1" : "", + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "*", + }, + { + // Capture-only E2E app: direct Media uploads resolve against its local API. + protocol: "http", + hostname: "localhost", + port: "3006", + pathname: "/uploads/**", + }, + ], + }, +}; + +export default nextConfig; diff --git a/scripts/codegen/setup-nextjs.sh b/scripts/codegen/setup-nextjs.sh index 197c7d8f0..e4cad18fe 100755 --- a/scripts/codegen/setup-nextjs.sh +++ b/scripts/codegen/setup-nextjs.sh @@ -34,6 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" DEST="$ROOT_DIR/codegen-projects/nextjs" CLI_BIN="$ROOT_DIR/packages/cli/dist/index.cjs" +SHADCN_VERSION="${SHADCN_VERSION:-4.19.1}" # ── Prerequisites ──────────────────────────────────────────────────────────── @@ -56,7 +57,7 @@ fi step "Scaffolding Next.js project with shadcn init -t next" mkdir -p "$ROOT_DIR/codegen-projects" cd "$ROOT_DIR/codegen-projects" -pnpm dlx shadcn@latest init -t next --no-monorepo --base radix --preset nova --name nextjs --yes +pnpm dlx "shadcn@$SHADCN_VERSION" init -t next --no-monorepo --base radix --preset nova --name nextjs --yes success "shadcn scaffold complete → $DEST" # ── Step 2: Remove .git and lock file ──────────────────────────────────────── @@ -95,7 +96,7 @@ success "Temporary Prisma artifacts removed" step "Adding shadcn UI components (checkbox, label, skeleton, input, sonner, dropdown-menu, separator)" cd "$DEST" -pnpm dlx shadcn@latest add checkbox label skeleton input sonner dropdown-menu separator empty field item --yes --overwrite +pnpm dlx "shadcn@$SHADCN_VERSION" add checkbox label skeleton input sonner dropdown-menu separator empty field item --yes --overwrite success "shadcn components added" # Request-aware hydration and SSG/ISR use distinct generated layouts. The E2E diff --git a/scripts/product-proof/check-assets.mjs b/scripts/product-proof/check-assets.mjs new file mode 100644 index 000000000..144a71512 --- /dev/null +++ b/scripts/product-proof/check-assets.mjs @@ -0,0 +1,432 @@ +#!/usr/bin/env node + +import { readFile, stat } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REQUIRED_TEXT_FIELDS = ["alt", "caption", "source"]; +const TEXT_SOURCE_EXTENSIONS = new Set([ + ".css", + ".html", + ".hbs", + ".js", + ".json", + ".jsx", + ".md", + ".mdx", + ".mjs", + ".svg", + ".ts", + ".tsx", +]); +const ALLOWED_TEXT_CONTEXTS = new Set([ + "canonicalWebsiteUrl", + "lowercaseNpmPackageToken", +]); + +function indexesOf(source, search) { + const indexes = []; + let offset = 0; + while (offset <= source.length - search.length) { + const index = source.indexOf(search, offset); + if (index === -1) break; + indexes.push(index); + offset = index + search.length; + } + return indexes; +} + +function allowedRanges(contents, rule) { + const ranges = []; + if (rule.context === "canonicalWebsiteUrl") { + for (const index of indexesOf(contents, rule.url)) { + ranges.push({ start: index, end: index + rule.url.length }); + } + return ranges; + } + + for (const index of indexesOf(contents, rule.token)) { + ranges.push({ start: index, end: index + rule.token.length }); + } + return ranges; +} + +function readSvgDimensions(source) { + const viewBox = source.match( + /viewBox=["']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["']/i, + ); + if (!viewBox) return null; + return { width: Number(viewBox[3]), height: Number(viewBox[4]) }; +} + +function readUInt24LE(buffer, offset) { + return ( + buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) + ); +} + +function readWebPDimensions(buffer) { + if ( + buffer.length < 30 || + buffer.toString("ascii", 0, 4) !== "RIFF" || + buffer.toString("ascii", 8, 12) !== "WEBP" + ) { + return null; + } + + let offset = 12; + while (offset + 8 <= buffer.length) { + const chunk = buffer.toString("ascii", offset, offset + 4); + const size = buffer.readUInt32LE(offset + 4); + const dataOffset = offset + 8; + + if (chunk === "VP8X" && dataOffset + 10 <= buffer.length) { + return { + width: readUInt24LE(buffer, dataOffset + 4) + 1, + height: readUInt24LE(buffer, dataOffset + 7) + 1, + }; + } + if (chunk === "VP8L" && dataOffset + 5 <= buffer.length) { + const bits = buffer.readUInt32LE(dataOffset + 1); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >>> 14) & 0x3fff) + 1, + }; + } + if (chunk === "VP8 " && dataOffset + 10 <= buffer.length) { + return { + width: buffer.readUInt16LE(dataOffset + 6) & 0x3fff, + height: buffer.readUInt16LE(dataOffset + 8) & 0x3fff, + }; + } + + offset = dataOffset + size + (size % 2); + } + return null; +} + +async function dimensionsFor(path, format) { + if (format === "svg") { + return readSvgDimensions(await readFile(path, "utf8")); + } + if (format === "webp") { + return readWebPDimensions(await readFile(path)); + } + return null; +} + +export async function checkAssetKit(manifestPath) { + const absoluteManifest = resolve(manifestPath); + const root = dirname(absoluteManifest); + const manifest = JSON.parse(await readFile(absoluteManifest, "utf8")); + const errors = []; + + if (manifest.version !== 1) errors.push("manifest.version must be 1"); + if ( + !Number.isInteger(manifest.assetRevision) || + manifest.assetRevision <= 0 + ) { + errors.push("manifest.assetRevision must be a positive integer"); + } + if (!Number.isInteger(manifest.kitMaxBytes) || manifest.kitMaxBytes <= 0) { + errors.push("manifest.kitMaxBytes must be a positive integer"); + } + if ( + !Array.isArray(manifest.forbiddenText) || + manifest.forbiddenText.length === 0 || + manifest.forbiddenText.some( + (value) => typeof value !== "string" || value.trim() === "", + ) + ) { + errors.push("manifest.forbiddenText must contain at least one string"); + } + const textSources = Array.isArray(manifest.textSources) + ? manifest.textSources.filter( + (value) => typeof value === "string" && value.trim() !== "", + ) + : []; + if ( + textSources.length === 0 || + textSources.length !== (manifest.textSources?.length ?? 0) + ) { + errors.push("manifest.textSources must contain at least one unique path"); + } + if (new Set(textSources).size !== textSources.length) { + errors.push("manifest.textSources must not contain duplicate paths"); + } + if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) { + errors.push("manifest.assets must contain at least one asset"); + return { errors, count: 0, totalBytes: 0 }; + } + + const prohibitedCopy = Array.isArray(manifest.forbiddenText) + ? manifest.forbiddenText + .filter((value) => typeof value === "string" && value.trim() !== "") + .map((value) => ({ original: value, normalized: value.toLowerCase() })) + : []; + const textSourceAllowlist = + manifest.textSourceAllowlist === undefined + ? {} + : manifest.textSourceAllowlist; + if ( + typeof textSourceAllowlist !== "object" || + textSourceAllowlist === null || + Array.isArray(textSourceAllowlist) + ) { + errors.push("manifest.textSourceAllowlist must be a path-to-terms object"); + } + const sourceAllowlist = new Map(); + if ( + typeof textSourceAllowlist === "object" && + textSourceAllowlist !== null && + !Array.isArray(textSourceAllowlist) + ) { + for (const [source, rules] of Object.entries(textSourceAllowlist)) { + if (!textSources.includes(source)) { + errors.push( + `manifest.textSourceAllowlist path must be declared in textSources: ${source}`, + ); + } + if (!Array.isArray(rules) || rules.length === 0) { + errors.push( + `manifest.textSourceAllowlist.${source} must contain context rules`, + ); + continue; + } + const validRules = []; + const ruleKeys = new Set(); + for (const rule of rules) { + if ( + typeof rule !== "object" || + rule === null || + Array.isArray(rule) || + typeof rule.term !== "string" || + rule.term.trim() === "" || + !ALLOWED_TEXT_CONTEXTS.has(rule.context) + ) { + errors.push( + `manifest.textSourceAllowlist.${source} contains an invalid context rule`, + ); + continue; + } + const term = rule.term; + if ( + !prohibitedCopy.some( + (forbidden) => forbidden.normalized === term.toLowerCase(), + ) + ) { + errors.push( + `manifest.textSourceAllowlist.${source} contains non-policy term "${term}"`, + ); + } + if (rule.context === "canonicalWebsiteUrl") { + let url; + try { + url = new URL(rule.url); + } catch { + url = null; + } + if ( + url?.protocol !== "https:" || + !rule.url.toLowerCase().includes(term.toLowerCase()) + ) { + errors.push( + `manifest.textSourceAllowlist.${source} canonicalWebsiteUrl must be an HTTPS URL containing "${term}"`, + ); + continue; + } + } + if ( + rule.context === "lowercaseNpmPackageToken" && + (typeof rule.token !== "string" || + rule.token !== rule.token.toLowerCase() || + rule.token !== term.toLowerCase() || + !/^@[a-z0-9._-]+\/[a-z0-9._/-]+$/.test(rule.token)) + ) { + errors.push( + `manifest.textSourceAllowlist.${source} lowercaseNpmPackageToken must be the exact lowercase package token for "${term}"`, + ); + continue; + } + const ruleKey = JSON.stringify(rule).toLowerCase(); + if (ruleKeys.has(ruleKey)) { + errors.push( + `manifest.textSourceAllowlist.${source} contains duplicate context rules`, + ); + continue; + } + ruleKeys.add(ruleKey); + validRules.push({ ...rule, normalizedTerm: term.toLowerCase() }); + } + sourceAllowlist.set(source, validRules); + } + } + const declaredTextSources = new Set(textSources); + const scannedTextPaths = new Set(); + for (const source of declaredTextSources) { + const sourcePath = resolve(root, source); + let contents; + try { + contents = await readFile(sourcePath, "utf8"); + scannedTextPaths.add(sourcePath); + } catch { + errors.push(`${source}: text source file does not exist`); + continue; + } + const normalizedContents = contents.toLowerCase(); + for (const forbidden of prohibitedCopy) { + const occurrences = indexesOf(normalizedContents, forbidden.normalized); + if (occurrences.length === 0) continue; + const matchingRules = (sourceAllowlist.get(source) ?? []).filter( + (rule) => rule.normalizedTerm === forbidden.normalized, + ); + const ranges = matchingRules.flatMap((rule) => + allowedRanges(contents, rule), + ); + const hasDisallowedOccurrence = occurrences.some( + (index) => + !ranges.some( + (range) => + index >= range.start && + index + forbidden.normalized.length <= range.end, + ), + ); + if (hasDisallowedOccurrence) { + const context = + matchingRules.length === 0 + ? "" + : ` outside an allowed ${matchingRules + .map((rule) => + rule.context === "lowercaseNpmPackageToken" + ? "lowercase npm package token" + : "canonical website URL", + ) + .join(" or ")}`; + errors.push( + `${source}: contains forbidden text "${forbidden.original}"${context}`, + ); + } + } + } + + let totalBytes = 0; + for (const asset of manifest.assets) { + const label = asset.file ?? ""; + if (!asset.file || typeof asset.file !== "string") { + errors.push(`${label}: file is required`); + continue; + } + for (const field of REQUIRED_TEXT_FIELDS) { + if (asset.decorative === true && field === "alt") continue; + if (typeof asset[field] !== "string" || asset[field].trim() === "") { + errors.push(`${label}: ${field} is required`); + } + } + if (typeof asset.source === "string" && asset.source.trim() !== "") { + if ( + TEXT_SOURCE_EXTENSIONS.has(extname(asset.source).toLowerCase()) && + !declaredTextSources.has(asset.source) + ) { + errors.push( + `${label}: textual source must be declared in manifest.textSources`, + ); + } + try { + await stat(resolve(root, asset.source)); + } catch { + errors.push(`${label}: source file does not exist`); + } + } + if (typeof asset.decorative !== "boolean") { + errors.push(`${label}: decorative must be true or false`); + } + + const path = resolve(root, asset.file); + let fileStat; + try { + fileStat = await stat(path); + } catch { + errors.push(`${label}: file does not exist`); + continue; + } + + totalBytes += fileStat.size; + if (!Number.isInteger(asset.maxBytes) || asset.maxBytes <= 0) { + errors.push(`${label}: maxBytes must be a positive integer`); + } else if (fileStat.size > asset.maxBytes) { + errors.push(`${label}: ${fileStat.size} bytes exceeds ${asset.maxBytes}`); + } + + const extension = extname(asset.file).slice(1).toLowerCase(); + if (asset.format !== extension) { + errors.push( + `${label}: format ${asset.format} does not match .${extension}`, + ); + } + const dimensions = await dimensionsFor(path, asset.format); + if (!dimensions) { + errors.push(`${label}: dimensions could not be read`); + } else if ( + dimensions.width !== asset.width || + dimensions.height !== asset.height + ) { + errors.push( + `${label}: expected ${asset.width}x${asset.height}, found ${dimensions.width}x${dimensions.height}`, + ); + } + + if (prohibitedCopy.length > 0) { + const searchable = [asset.alt, asset.caption]; + if (asset.format === "svg" && !scannedTextPaths.has(path)) { + searchable.push(await readFile(path, "utf8")); + } + const normalizedSearchable = searchable + .filter((value) => typeof value === "string") + .map((value) => value.toLowerCase()); + for (const forbidden of prohibitedCopy) { + if ( + normalizedSearchable.some((value) => + value.includes(forbidden.normalized), + ) + ) { + errors.push( + `${label}: contains forbidden text "${forbidden.original}"`, + ); + } + } + } + } + + if (totalBytes > manifest.kitMaxBytes) { + errors.push(`kit: ${totalBytes} bytes exceeds ${manifest.kitMaxBytes}`); + } + + return { errors, count: manifest.assets.length, totalBytes }; +} + +async function main() { + const manifestPath = process.argv[2]; + if (!manifestPath) { + console.error( + "Usage: node scripts/product-proof/check-assets.mjs ", + ); + process.exitCode = 1; + return; + } + + const result = await checkAssetKit(manifestPath); + if (result.errors.length > 0) { + for (const error of result.errors) console.error(`- ${error}`); + process.exitCode = 1; + return; + } + + const noun = result.count === 1 ? "asset" : "assets"; + console.log( + `${result.count} ${noun} checked; ${result.totalBytes} bytes total`, + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/product-proof/check-assets.test.mjs b/scripts/product-proof/check-assets.test.mjs new file mode 100644 index 000000000..7b768201c --- /dev/null +++ b/scripts/product-proof/check-assets.test.mjs @@ -0,0 +1,451 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const checker = new URL("./check-assets.mjs", import.meta.url); + +async function writeFixture(manifest, { writeSources = true } = {}) { + const root = await mkdtemp(join(tmpdir(), "btst-product-proof-")); + const manifestPath = join(root, "manifest.json"); + const assetFiles = new Set(manifest.assets.map((asset) => asset.file)); + + for (const asset of manifest.assets) { + const path = join(root, asset.file); + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + 'Proof', + ); + if (asset.source && writeSources) { + const sourcePath = join(root, asset.source); + await mkdir(dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, "deterministic fixture source"); + } + } + if (writeSources) { + for (const source of manifest.textSources ?? []) { + if (assetFiles.has(source)) continue; + const sourcePath = join(root, source); + await mkdir(dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, "deterministic fixture source"); + } + } + + await writeFile(manifestPath, JSON.stringify(manifest)); + return manifestPath; +} + +test("accepts a complete asset contract within its byte budget", async () => { + const manifestPath = await writeFixture({ + version: 1, + assetRevision: 1, + kitMaxBytes: 1_800_000, + forbiddenText: ["Better Stack", "john@example.com"], + textSources: ["scripts/product-proof/render-diagrams.mjs"], + assets: [ + { + file: "ownership.svg", + format: "svg", + width: 1600, + height: 900, + maxBytes: 100_000, + alt: "Diagram showing a React application keeping its code, data, and deployment after adding BTST.", + caption: + "BTST runs inside the application while the team keeps every operating boundary.", + source: "scripts/product-proof/render-diagrams.mjs", + decorative: false, + }, + ], + }); + + const result = spawnSync(process.execPath, [checker.pathname, manifestPath], { + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /1 asset checked/); +}); + +test("rejects missing evidence metadata, dimension drift, and exceeded budgets", async () => { + const manifestPath = await writeFixture({ + version: 1, + assetRevision: 1, + kitMaxBytes: 10, + forbiddenText: ["Proof"], + textSources: ["render-source.mjs"], + assets: [ + { + file: "blog-proof.svg", + format: "svg", + width: 800, + height: 450, + maxBytes: 20, + alt: "", + caption: "", + source: "", + decorative: false, + }, + ], + }); + + const result = spawnSync(process.execPath, [checker.pathname, manifestPath], { + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /alt is required/); + assert.match(result.stderr, /caption is required/); + assert.match(result.stderr, /source is required/); + assert.match(result.stderr, /expected 800x450, found 1600x900/); + assert.match(result.stderr, /bytes exceeds 20/); + assert.match(result.stderr, /kit: .* bytes exceeds 10/); + assert.match(result.stderr, /contains forbidden text "Proof"/); +}); + +test("requires the prohibited-copy policy and textual source contract", async () => { + const manifestPath = await writeFixture( + { + version: 1, + assetRevision: 1, + kitMaxBytes: 1_800_000, + assets: [ + { + file: "ownership.svg", + format: "svg", + width: 1600, + height: 900, + maxBytes: 100_000, + alt: "BTST ownership boundaries.", + caption: "The app remains yours.", + source: "missing-source.mjs", + decorative: false, + }, + ], + }, + { writeSources: false }, + ); + + const result = spawnSync(process.execPath, [checker.pathname, manifestPath], { + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /manifest.forbiddenText must contain at least one string/, + ); + assert.match( + result.stderr, + /manifest.textSources must contain at least one unique path/, + ); +}); + +test("reads dimensions from generated WebP evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "btst-product-proof-webp-")); + const assetPath = join(root, "blog-proof.webp"); + const manifestPath = join(root, "manifest.json"); + await writeFile( + assetPath, + Buffer.from( + "UklGRiIAAABXRUJQVlA4TBYAAAAvAQAAAA/wDaQzZHwEef5DCxWI6H8A", + "base64", + ), + ); + await writeFile( + manifestPath, + JSON.stringify({ + version: 1, + assetRevision: 1, + kitMaxBytes: 1_800_000, + forbiddenText: ["Better Stack", "john@example.com"], + textSources: ["capture.mjs"], + assets: [ + { + file: "blog-proof.webp", + format: "webp", + width: 2, + height: 1, + maxBytes: 100_000, + alt: "BTST Blog showing a published product update.", + caption: + "A real Blog route is the visible result of one installed plugin.", + source: "capture.mjs", + decorative: false, + }, + ], + }), + ); + await writeFile(join(root, "capture.mjs"), "deterministic fixture source"); + + const result = spawnSync(process.execPath, [checker.pathname, manifestPath], { + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); +}); + +test("rejects case variants of prohibited copy in raster source data", async () => { + const root = await mkdtemp(join(tmpdir(), "btst-product-proof-source-")); + const assetPath = join(root, "blog-proof.webp"); + const manifestPath = join(root, "manifest.json"); + await writeFile( + assetPath, + Buffer.from( + "UklGRiIAAABXRUJQVlA4TBYAAAAvAQAAAA/wDaQzZHwEef5DCxWI6H8A", + "base64", + ), + ); + await writeFile(join(root, "capture.mjs"), "deterministic capture source"); + await writeFile( + join(root, "dogfood-data.json"), + JSON.stringify({ title: "A bEtTeR sTaCk release" }), + ); + await writeFile( + manifestPath, + JSON.stringify({ + version: 1, + assetRevision: 1, + kitMaxBytes: 1_800_000, + forbiddenText: ["Better Stack"], + textSources: ["capture.mjs", "dogfood-data.json"], + assets: [ + { + file: "blog-proof.webp", + format: "webp", + width: 2, + height: 1, + maxBytes: 100_000, + alt: "BTST Blog showing a published product update.", + caption: "A real Blog route from the generated application.", + source: "capture.mjs", + decorative: false, + }, + ], + }), + ); + + const result = spawnSync(process.execPath, [checker.pathname, manifestPath], { + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /dogfood-data.json: contains forbidden text "Better Stack"/, + ); +}); + +test("limits a prohibited display-token exception to an explicit source", async () => { + const manifestPath = await writeFixture({ + version: 1, + assetRevision: 1, + kitMaxBytes: 1_800_000, + forbiddenText: ["@BTST/STACK"], + textSources: ["stack.ts", "navbar.tsx"], + textSourceAllowlist: { + "stack.ts": [ + { + term: "@BTST/STACK", + context: "lowercaseNpmPackageToken", + token: "@btst/stack", + }, + ], + }, + assets: [ + { + file: "ownership.svg", + format: "svg", + width: 1600, + height: 900, + maxBytes: 100_000, + alt: "BTST ownership boundaries.", + caption: "The app remains yours.", + source: "navbar.tsx", + decorative: false, + }, + ], + }); + await writeFile( + join(dirname(manifestPath), "stack.ts"), + 'import { createStack } from "@btst/stack";', + ); + + const allowed = spawnSync( + process.execPath, + [checker.pathname, manifestPath], + { + encoding: "utf8", + }, + ); + assert.equal(allowed.status, 0, allowed.stderr); + + await writeFile( + join(dirname(manifestPath), "stack.ts"), + 'import { createStack } from "@btst/stack";\nconst lockup = "@BTST/STACK";', + ); + const rejectedInAllowedSource = spawnSync( + process.execPath, + [checker.pathname, manifestPath], + { + encoding: "utf8", + }, + ); + assert.equal(rejectedInAllowedSource.status, 1); + assert.match( + rejectedInAllowedSource.stderr, + /stack\.ts: contains forbidden text "@BTST\/STACK" outside an allowed lowercase npm package token/, + ); + + await writeFile( + join(dirname(manifestPath), "navbar.tsx"), + "@btst/stack", + ); + const rejected = spawnSync( + process.execPath, + [checker.pathname, manifestPath], + { + encoding: "utf8", + }, + ); + assert.equal(rejected.status, 1); + assert.match( + rejected.stderr, + /navbar\.tsx: contains forbidden text "@BTST\/STACK"/, + ); +}); + +test("rejects missing declared textual source files", async () => { + const manifestPath = await writeFixture( + { + version: 1, + assetRevision: 1, + kitMaxBytes: 1_800_000, + forbiddenText: ["Better Stack"], + textSources: ["missing-source.mjs"], + assets: [ + { + file: "ownership.svg", + format: "svg", + width: 1600, + height: 900, + maxBytes: 100_000, + alt: "BTST ownership boundaries.", + caption: "The app remains yours.", + source: "missing-source.mjs", + decorative: false, + }, + ], + }, + { writeSources: false }, + ); + + const result = spawnSync(process.execPath, [checker.pathname, manifestPath], { + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /missing-source.mjs: text source file does not exist/, + ); +}); + +test("rejects a drifted Blog backend registration before rendering proof", async () => { + const { assertBlogRegistrationSources } = await import( + "../../e2e/product-proof/registration-contract.mjs" + ); + const backendSource = ` + createBackendStack({ + plugins: { + blog: renamedBlogPlugin({ hooks: blogHooks }), + }, + }); + `; + const clientSource = ` + createClientStack({ + plugins: { + blog: blogClientPlugin({ + seo: { + siteName: "BTST Blog", + author: "BTST Team", + }, + }), + }, + }); + `; + + assert.throws( + () => assertBlogRegistrationSources({ backendSource, clientSource }), + /Blog backend registration proof drifted from scripts\/codegen\/files\/nextjs\/lib\/stack\.ts/, + ); +}); + +test("rejects a drifted Blog client registration before rendering proof", async () => { + const { assertBlogRegistrationSources } = await import( + "../../e2e/product-proof/registration-contract.mjs" + ); + const backendSource = ` + createBackendStack({ + plugins: { + blog: blogBackendPlugin({ hooks: blogHooks }), + }, + }); + `; + const clientSource = ` + createClientStack({ + plugins: { + blog: renamedBlogClientPlugin({ + seo: { + siteName: "BTST Blog", + author: "BTST Team", + }, + }), + }, + }); + `; + + assert.throws( + () => assertBlogRegistrationSources({ backendSource, clientSource }), + /Blog client registration proof drifted from scripts\/codegen\/files\/nextjs\/lib\/stack-client\.tsx/, + ); +}); + +test("derives the displayed Blog proof from the current generated-app sources", async () => { + const { assertBlogRegistrationSources } = await import( + "../../e2e/product-proof/registration-contract.mjs" + ); + const [backendSource, clientSource] = await Promise.all([ + readFile("scripts/codegen/files/nextjs/lib/stack.ts", "utf8"), + readFile("scripts/codegen/files/nextjs/lib/stack-client.tsx", "utf8"), + ]); + const proof = assertBlogRegistrationSources({ + backendSource, + clientSource, + }); + + assert.deepEqual(proof.backendExcerpt, [ + "blog: blogBackendPlugin({ hooks: blogHooks }),", + ]); + assert.ok( + proof.clientExcerpt.some( + (line) => line.trim() === 'siteName: "BTST Blog",', + ), + "client excerpt includes current siteName source", + ); + assert.ok( + proof.clientExcerpt.some((line) => line.trim() === 'author: "BTST Team",'), + "client excerpt includes current author source", + ); + assert.ok( + proof.clientExcerpt.includes("\t// … hooks unchanged"), + "client excerpt labels omitted hooks", + ); + assert.equal(proof.clientExcerpt.at(-1), "}),"); + for (const line of proof.clientExcerpt) { + if (line.includes("// …")) continue; + assert.ok(clientSource.includes(line), `client source includes ${line}`); + } +});