// The `frontend` feature: the product-level configure screen, served by the // PRODUCT container at /configure and iframed by ctl at // manifest.ui.configScreenUrl. // // Not to be confused with either operator screen (views.ts, dashboard.ts): // those are per-INSTANCE and served by Studio. This one is served once, by the // single control-plane container the daemon runs, and its job is to collect the // config a product-template is built from. // // The trap is registration-time. The daemon probes configScreenUrl and refuses // the product with CONFIG_SCREEN_UNREACHABLE unless it answers 2xx — which is // why declaring it, serving it and building it are one feature rather than // three independent edits, and why the dev script builds dist before starting // the backend that serves it. import type { GeneratedFile, ShapeContext } from "../create-product.ts"; import { TEST_SETUP_TS, UI_TEST_DEV_DEPENDENCIES, VITEST_CONFIG_TS } from "./ui-tests.ts"; export const CONFIG_SCREEN_URL = "/configure"; function packageJson(ctx: ShapeContext): string { return `${JSON.stringify( { name: `${ctx.scope}/frontend`, version: "0.0.1", private: true, type: "module", scripts: { // Vite's own dev server, for iterating on the screen with HMR. The // BACKEND serves dist/, so this is not what a registered dev product // shows — see the root `dev` script. dev: "bunx vite", build: "bunx vite build", clean: "rm -rf dist", typecheck: "bunx tsc --noEmit", test: "vitest run", }, dependencies: { [`${ctx.scope}/shared`]: "workspace:*", react: "^19.0.0", "react-dom": "^19.0.0", }, devDependencies: { "@types/node": "^22.10.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^6.0.1", typescript: "^5.7.2", vite: "^8.0.0", ...UI_TEST_DEV_DEPENDENCIES, }, }, null, 2, )}\n`; } // vite.config.ts stays out of `include` — it imports vite itself, and checking // the app's own sources should not need the bundler installed. const TSCONFIG = `{ "compilerOptions": { "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "noEmit": true }, "include": ["src"] } `; const VITE_CONFIG_TS = `import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; // \`base: "./"\` — the screen is iframed under the product's own path prefix in // the norsk-ctl UI, so absolute asset URLs escape it. export default defineConfig({ base: "./", plugins: [react()], build: { outDir: "dist", emptyOutDir: true }, }); `; function indexHtml(ctx: ShapeContext): string { return ` ${ctx.name} — configure
`; } const MAIN_TSX = `import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; const root = document.getElementById("root"); if (!root) throw new Error("root element missing"); ReactDOM.createRoot(root).render( , ); `; function appTsx(ctx: ShapeContext): string { return `// PLACEHOLDER configure screen. The starter template needs no configuration, // so this says so rather than pretending to collect something. // // When the product grows real config: POST the ProductConfig this screen // collects to /api/product-template and the backend returns the built tar. // ProductConfigSchema (shared/src/schemas/config.ts) is the contract for what // belongs here — it is the same schema the route validates against, so this // workspace depends on ${ctx.scope}/shared rather than restating the shape. export default function App() { return (

${ctx.name}

The starter template needs no configuration. Launch it from norsk-ctl to bring up the Studio + Norsk stack this product composes.

Replace this screen when the product grows launch-time config, or drop the frontend workspace if it never does.

); } `; } function appTestTsx(ctx: ShapeContext): string { return `import { render, screen } from "@testing-library/react"; import { expect, test } from "vitest"; import App from "./App.tsx"; test("names the product", () => { render(); expect(screen.getByRole("heading", { name: "${ctx.name}" })).toBeInTheDocument(); }); `; } const INDEX_CSS = `:root { color-scheme: light dark; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } body { margin: 0; padding: 2rem; } `; export function frontendFiles(ctx: ShapeContext): GeneratedFile[] { return [ { path: "frontend/package.json", content: packageJson(ctx) }, { path: "frontend/tsconfig.json", content: TSCONFIG }, { path: "frontend/vite.config.ts", content: VITE_CONFIG_TS }, { path: "frontend/vitest.config.ts", content: VITEST_CONFIG_TS }, { path: "frontend/index.html", content: indexHtml(ctx) }, { path: "frontend/src/main.tsx", content: MAIN_TSX }, { path: "frontend/src/App.tsx", content: appTsx(ctx) }, { path: "frontend/src/App.test.tsx", content: appTestTsx(ctx) }, { path: "frontend/src/test/setup.ts", content: TEST_SETUP_TS }, { path: "frontend/src/index.css", content: INDEX_CSS }, ]; } /** The express block serving the built screen at configScreenUrl. */ export const FRONTEND_SERVER_BLOCK = ` // The configure screen at manifest.ui.configScreenUrl. The daemon probes this // at \`product add\` and refuses the product with CONFIG_SCREEN_UNREACHABLE if // it does not answer 2xx — so it must be BUILT (\`bun run build:frontend\`, // which the image build and the root \`dev\` script both do) before the // product is registered. app.use("${CONFIG_SCREEN_URL}", express.static(FRONTEND_DIST)); app.get("${CONFIG_SCREEN_URL}/*splat", (_req, res) => { res.sendFile(path.join(FRONTEND_DIST, "index.html")); });`;