@warlock.js/core 5.3.2 → 5.5.0

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 (32) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/esm/cli/cli-commands.utils.mjs +27 -9
  3. package/esm/cli/cli-commands.utils.mjs.map +1 -1
  4. package/esm/dev-server/shortcuts.mjs +27 -6
  5. package/esm/dev-server/shortcuts.mjs.map +1 -1
  6. package/esm/generations/features/react-email.feature.mjs +3 -9
  7. package/esm/generations/features/react-email.feature.mjs.map +1 -1
  8. package/esm/generations/features/shared/patch-tsconfig-include.mjs +85 -0
  9. package/esm/generations/features/shared/patch-tsconfig-include.mjs.map +1 -0
  10. package/esm/generations/features/shared/relocate-conflicting-home-route.mjs +153 -0
  11. package/esm/generations/features/shared/relocate-conflicting-home-route.mjs.map +1 -0
  12. package/esm/generations/features/shared/resolve-contact-scaffold.mjs +45 -0
  13. package/esm/generations/features/shared/resolve-contact-scaffold.mjs.map +1 -0
  14. package/esm/generations/features/web.feature.mjs +18 -93
  15. package/esm/generations/features/web.feature.mjs.map +1 -1
  16. package/esm/generations/stubs.mjs +59 -19
  17. package/esm/generations/stubs.mjs.map +1 -1
  18. package/llms-full.txt +60 -60
  19. package/llms.txt +3 -3
  20. package/package.json +12 -12
  21. package/skills/create-controller/SKILL.md +1 -1
  22. package/skills/create-module/SKILL.md +11 -11
  23. package/skills/hash-password/SKILL.md +4 -4
  24. package/skills/run-app/SKILL.md +16 -16
  25. package/skills/send-mail/SKILL.md +2 -2
  26. package/skills/use-model-transformers/SKILL.md +2 -2
  27. package/skills/use-repository/SKILL.md +1 -1
  28. package/skills/validate-input/SKILL.md +1 -1
  29. package/skills/warlock-doctor/SKILL.md +3 -3
  30. package/skills/warlock-routes/SKILL.md +10 -10
  31. package/skills/write-cli-command/SKILL.md +1 -1
  32. package/skills/write-seeder/SKILL.md +8 -8
@@ -0,0 +1,45 @@
1
+ import { colors } from "@mongez/copper";
2
+
3
+ //#region ../core/src/generations/features/shared/resolve-contact-scaffold.ts
4
+ const CONTROLLER_PATH = "src/app/contact/controllers/contact.controller.ts";
5
+ const ROUTES_PATH = "src/app/contact/routes.ts";
6
+ /**
7
+ * Decide which of the two contact-module files are safe to (re)write, and
8
+ * report loudly about the ones that are not.
9
+ *
10
+ * Pure by design: no filesystem access here, so a test can exercise every
11
+ * combination directly. {@link completeWebInstallation} in `web.feature.ts`
12
+ * is the thin I/O wrapper — it stats both files, calls this, writes back
13
+ * whatever this says to write, and prints whatever this says to print.
14
+ *
15
+ * `src/web/root.tsx` is the "a human has been here" sentinel for the web
16
+ * layer as a whole, but `src/app/contact/**` is an ordinary application
17
+ * module the web feature happens to also scaffold — nothing stops a project
18
+ * from having its own `contact` module (with its own controller and routes)
19
+ * independently of ever having run `warlock add web`. Trusting root.tsx's
20
+ * absence as license to write these two files meant `warlock add web` could
21
+ * silently overwrite a human's existing `contact` module. Exactly like
22
+ * {@link relocateConflictingHomeRoute} already insists for the home route:
23
+ * "`warlock add web` runs against a project a human has been living in, and
24
+ * silently unlinking their code is not a thing an `add` command gets to do."
25
+ * So each file gets its own independent existence guard, and an existing
26
+ * file is skipped — never overwritten, never a reason to abort the rest of
27
+ * the install.
28
+ */
29
+ function resolveContactScaffold(state) {
30
+ const writeController = !state.controllerExists;
31
+ const writeRoutes = !state.routesExists;
32
+ const messages = [];
33
+ if (state.controllerExists) messages.push(`${colors.yellowBright("!")} ${colors.yellowBright(CONTROLLER_PATH)} already exists, skipping — the contact form's endpoint is missing this controller's logic unless it already handles ${colors.yellowBright("POST /api/contact")}, so the form will 404 until you wire it yourself.`);
34
+ if (state.routesExists) messages.push(`${colors.yellowBright("!")} ${colors.yellowBright(ROUTES_PATH)} already exists, skipping — the contact form's endpoint (${colors.yellowBright("POST /api/contact")}) was not registered here, so the form will 404 until you wire it yourself.`);
35
+ if (writeController && writeRoutes) messages.push(`${colors.green("✓")} Created POST /api/contact starter route`);
36
+ return {
37
+ writeController,
38
+ writeRoutes,
39
+ messages
40
+ };
41
+ }
42
+
43
+ //#endregion
44
+ export { resolveContactScaffold };
45
+ //# sourceMappingURL=resolve-contact-scaffold.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-contact-scaffold.mjs","names":[],"sources":["../../../../../../../../../core/src/generations/features/shared/resolve-contact-scaffold.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\n\n/** Whether each contact-module file `completeWebInstallation` writes already exists on disk. */\nexport type ContactScaffoldState = {\n controllerExists: boolean;\n routesExists: boolean;\n};\n\n/** Which contact-module files may be written, and what to tell the user. */\nexport type ContactScaffoldPlan = {\n writeController: boolean;\n writeRoutes: boolean;\n messages: string[];\n};\n\nconst CONTROLLER_PATH = \"src/app/contact/controllers/contact.controller.ts\";\nconst ROUTES_PATH = \"src/app/contact/routes.ts\";\n\n/**\n * Decide which of the two contact-module files are safe to (re)write, and\n * report loudly about the ones that are not.\n *\n * Pure by design: no filesystem access here, so a test can exercise every\n * combination directly. {@link completeWebInstallation} in `web.feature.ts`\n * is the thin I/O wrapper — it stats both files, calls this, writes back\n * whatever this says to write, and prints whatever this says to print.\n *\n * `src/web/root.tsx` is the \"a human has been here\" sentinel for the web\n * layer as a whole, but `src/app/contact/**` is an ordinary application\n * module the web feature happens to also scaffold — nothing stops a project\n * from having its own `contact` module (with its own controller and routes)\n * independently of ever having run `warlock add web`. Trusting root.tsx's\n * absence as license to write these two files meant `warlock add web` could\n * silently overwrite a human's existing `contact` module. Exactly like\n * {@link relocateConflictingHomeRoute} already insists for the home route:\n * \"`warlock add web` runs against a project a human has been living in, and\n * silently unlinking their code is not a thing an `add` command gets to do.\"\n * So each file gets its own independent existence guard, and an existing\n * file is skipped — never overwritten, never a reason to abort the rest of\n * the install.\n */\nexport function resolveContactScaffold(state: ContactScaffoldState): ContactScaffoldPlan {\n const writeController = !state.controllerExists;\n const writeRoutes = !state.routesExists;\n const messages: string[] = [];\n\n if (state.controllerExists) {\n messages.push(\n `${colors.yellowBright(\"!\")} ${colors.yellowBright(CONTROLLER_PATH)} already exists, skipping — ` +\n \"the contact form's endpoint is missing this controller's logic unless it already handles \" +\n `${colors.yellowBright(\"POST /api/contact\")}, so the form will 404 until you wire it yourself.`,\n );\n }\n\n if (state.routesExists) {\n messages.push(\n `${colors.yellowBright(\"!\")} ${colors.yellowBright(ROUTES_PATH)} already exists, skipping — ` +\n `the contact form's endpoint (${colors.yellowBright(\"POST /api/contact\")}) was not registered here, ` +\n \"so the form will 404 until you wire it yourself.\",\n );\n }\n\n if (writeController && writeRoutes) {\n messages.push(`${colors.green(\"✓\")} Created POST /api/contact starter route`);\n }\n\n return { writeController, writeRoutes, messages };\n}\n"],"mappings":";;;AAeA,MAAM,kBAAkB;AACxB,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;AAyBpB,SAAgB,uBAAuB,OAAkD;CACvF,MAAM,kBAAkB,CAAC,MAAM;CAC/B,MAAM,cAAc,CAAC,MAAM;CAC3B,MAAM,WAAqB,CAAC;CAE5B,IAAI,MAAM,kBACR,SAAS,KACP,GAAG,OAAO,aAAa,GAAG,EAAE,GAAG,OAAO,aAAa,eAAe,EAAE,uHAE/D,OAAO,aAAa,mBAAmB,EAAE,mDAChD;CAGF,IAAI,MAAM,cACR,SAAS,KACP,GAAG,OAAO,aAAa,GAAG,EAAE,GAAG,OAAO,aAAa,WAAW,EAAE,2DAC9B,OAAO,aAAa,mBAAmB,EAAE,4EAE7E;CAGF,IAAI,mBAAmB,aACrB,SAAS,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE,yCAAyC;CAG9E,OAAO;EAAE;EAAiB;EAAa;CAAS;AAClD"}
@@ -2,6 +2,8 @@ import { rootPath, srcPath } from "../../utils/paths.mjs";
2
2
  import "../../utils/index.mjs";
3
3
  import { webContactControllerStub, webContactRoutesStub, webHomePageStub, webHomeRegisterStub, webRootStub } from "../stubs.mjs";
4
4
  import { INSTALLED_WARLOCK_VERSION } from "./types.mjs";
5
+ import { relocateConflictingHomeRoute } from "./shared/relocate-conflicting-home-route.mjs";
6
+ import { resolveContactScaffold } from "./shared/resolve-contact-scaffold.mjs";
5
7
  import { colors } from "@mongez/copper";
6
8
  import { ensureDirectoryAsync, fileExistsAsync, getFileAsync, putFileAsync } from "@warlock.js/fs";
7
9
 
@@ -47,92 +49,6 @@ async function registerWebConnector() {
47
49
  console.log(`${colors.green("✓")} Registered webConnector in warlock.config.ts`);
48
50
  }
49
51
  /**
50
- * The app routes file the project template registers `GET /` in. Only this one
51
- * path is inspected: `warlock add web` is not a codebase-wide route auditor, and
52
- * a project that keeps its routes elsewhere lands on the `absent` outcome below,
53
- * which writes the page exactly as before.
54
- */
55
- const APP_ROUTES_FILE = "app/shared/routes.ts";
56
- /**
57
- * A TOP-LEVEL `router.get("/", ...)` — anchored at column 0 on purpose.
58
- *
59
- * Routes nested in a `router.group({ prefix: "/x" }, ...)` are indented by every
60
- * formatter this codebase runs, and their real path is `/x`, not `/`. Anchoring
61
- * is what keeps the notifications feature's own `router.get("/", ...)` (inside
62
- * the `/notifications` group) from reading as a homepage collision.
63
- *
64
- * Only the path literal is captured. The handler — a bare identifier in the
65
- * template, but possibly an inline arrow spanning lines — is never matched, so
66
- * the rewrite below cannot damage it.
67
- */
68
- const TOP_LEVEL_ROOT_GET = /^router\s*\.\s*get\(\s*(["'`])\/\1/gm;
69
- /**
70
- * Whether `/welcome` is already spoken for, so relocating onto it would trade
71
- * one duplicate-route 500 for another.
72
- */
73
- const TOP_LEVEL_WELCOME_GET = /^router\s*\.\s*get\(\s*(["'`])\/welcome\1/m;
74
- /**
75
- * Make room for a page that declares `route.path = "/"`.
76
- *
77
- * The project template registers `router.get("/", homePageController)` and the
78
- * page stub declares `route.path = "/"`. Fastify rejects the second registration
79
- * (`Method 'GET' already declared for route '/'`) and the homepage 500s at
80
- * request time — so `warlock add web` cannot just write the page and hope.
81
- *
82
- * Of the three ways out, this RELOCATES the JSON route to `/welcome` rather than
83
- * deleting it or refusing to scaffold:
84
- *
85
- * - Deleting the controller is what the scaffolder's own `react` feature does,
86
- * but it may do that: it owns the file it is deleting, seconds after writing
87
- * it. `warlock add web` runs against a project a human has been living in, and
88
- * silently unlinking their code is not a thing an `add` command gets to do.
89
- * - Writing the page anyway and printing a warning ships a project whose
90
- * homepage 500s. A warning above a broken app is still a broken app.
91
- * - Relocating keeps BOTH surfaces working: the React homepage takes `/`, the
92
- * JSON welcome answers at `/welcome`, and no line of user code disappears.
93
- *
94
- * Only the exact top-level shape is rewritten, and only the path literal inside
95
- * it. Anything else that claims `/` is reported and left completely alone — we
96
- * do not guess at code we cannot recognise.
97
- */
98
- async function relocateConflictingHomeRoute() {
99
- const routesPath = srcPath(APP_ROUTES_FILE);
100
- if (!await fileExistsAsync(routesPath)) return { outcome: "absent" };
101
- let current;
102
- try {
103
- current = await getFileAsync(routesPath);
104
- } catch (error) {
105
- return {
106
- outcome: "failed",
107
- reason: `could not be read (${error.message})`
108
- };
109
- }
110
- const matches = current.match(TOP_LEVEL_ROOT_GET) ?? [];
111
- if (matches.length === 0) return { outcome: "absent" };
112
- if (matches.length > 1) return {
113
- outcome: "conflict",
114
- reason: `declares ${matches.length} top-level GET "/" routes`
115
- };
116
- if (TOP_LEVEL_WELCOME_GET.test(current)) return {
117
- outcome: "conflict",
118
- reason: "already declares GET \"/welcome\", so the usual relocation target is taken"
119
- };
120
- const next = current.replace(TOP_LEVEL_ROOT_GET, (match, quote) => match.replace(`${quote}/${quote}`, `${quote}/welcome${quote}`));
121
- if (next === current) return {
122
- outcome: "conflict",
123
- reason: "its GET \"/\" route could not be rewritten"
124
- };
125
- try {
126
- await putFileAsync(routesPath, next);
127
- } catch (error) {
128
- return {
129
- outcome: "failed",
130
- reason: `could not be written (${error.message})`
131
- };
132
- }
133
- return { outcome: "relocated" };
134
- }
135
- /**
136
52
  * Scaffold the smallest page layer that renders, and register the connector.
137
53
  *
138
54
  * `src/web/root.tsx` is the sentinel for "already scaffolded" — the framework
@@ -146,20 +62,29 @@ async function completeWebInstallation(_options) {
146
62
  await putFileAsync(rootFile, webRootStub);
147
63
  console.log(`${colors.green("✓")} Created src/web/root.tsx`);
148
64
  const collision = await relocateConflictingHomeRoute();
149
- if (collision.outcome === "relocated") console.log(`${colors.green("✓")} Moved the existing ${colors.yellowBright("GET \"/\"")} route to ${colors.yellowBright("\"/welcome\"")} in ${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} — the new page owns \`/\` now, and the JSON welcome route still answers at /welcome.`);
65
+ if (collision.outcome === "relocated") console.log(`${colors.green("✓")} Moved the existing ${colors.yellowBright("GET \"/\"")} route to ${colors.yellowBright("\"/welcome\"")} in ${colors.yellowBright(`src/${collision.relativePath}`)} — the new page owns \`/\` now, and the JSON welcome route still answers at /welcome.`);
150
66
  if (collision.outcome === "conflict" || collision.outcome === "failed") {
151
67
  const verb = collision.outcome === "failed" ? colors.redBright("✗") : colors.yellowBright("!");
152
- console.log(`${verb} Did not create src/web/index.page.tsx: ${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} ${collision.reason}.\n The page stub declares ${colors.yellowBright("route.path = \"/\"")}, and two handlers on one path is a 500 at request time, not a startup error.
153
- Free up ${colors.yellowBright("GET \"/\"")} in that file — move it to a path of its own, or remove it — then create src/web/index.page.tsx yourself. Giving the page a \`route\` other than \`/\` works too.`);
68
+ console.log(`${verb} Did not create src/web/index.page.tsx: ${collision.reason}.\n The page stub declares ${colors.yellowBright("route.path = \"/\"")}, and two handlers on one path is a 500 at request time, not a startup error.
69
+ Free up ${colors.yellowBright("GET \"/\"")} under src/app — move it to a path of its own, or remove it — then create src/web/index.page.tsx yourself. Giving the page a \`route\` other than \`/\` works too.`);
154
70
  process.exitCode = 1;
155
71
  } else {
156
72
  await putFileAsync(srcPath("web/index.page.tsx"), webHomePageStub);
157
73
  await putFileAsync(srcPath("web/index.register.ts"), webHomeRegisterStub);
158
- await ensureDirectoryAsync(srcPath("app/contact/controllers"));
159
- await putFileAsync(srcPath("app/contact/controllers/contact.controller.ts"), webContactControllerStub);
160
- await putFileAsync(srcPath("app/contact/routes.ts"), webContactRoutesStub);
74
+ const contactPlan = resolveContactScaffold({
75
+ controllerExists: await fileExistsAsync(srcPath("app/contact/controllers/contact.controller.ts")),
76
+ routesExists: await fileExistsAsync(srcPath("app/contact/routes.ts"))
77
+ });
78
+ if (contactPlan.writeController) {
79
+ await ensureDirectoryAsync(srcPath("app/contact/controllers"));
80
+ await putFileAsync(srcPath("app/contact/controllers/contact.controller.ts"), webContactControllerStub);
81
+ }
82
+ if (contactPlan.writeRoutes) {
83
+ await ensureDirectoryAsync(srcPath("app/contact"));
84
+ await putFileAsync(srcPath("app/contact/routes.ts"), webContactRoutesStub);
85
+ }
161
86
  console.log(`${colors.green("✓")} Created src/web/index.page.tsx`);
162
- console.log(`${colors.green("✓")} Created POST /api/contact starter route`);
87
+ for (const message of contactPlan.messages) console.log(message);
163
88
  }
164
89
  }
165
90
  await registerWebConnector();
@@ -1 +1 @@
1
- {"version":3,"file":"web.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/web.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport {\r\n ensureDirectoryAsync,\r\n fileExistsAsync,\r\n getFileAsync,\r\n putFileAsync,\r\n} from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport {\n webContactControllerStub,\n webContactRoutesStub,\n webHomePageStub,\n webHomeRegisterStub,\n webRootStub,\n} from \"../stubs\";\nimport { type FeatureDefinition, INSTALLED_WARLOCK_VERSION } from \"./types\";\n\r\n/**\r\n * Register the WebConnector in `warlock.config.ts`, and ONLY there.\r\n *\r\n * It belongs to the config array or to app code, never both. Both halves are\r\n * registered before app code loads — the CLI preloader in dev, the generated\r\n * entry in production — so also calling `connectorsManager.register(...)` in\r\n * `src/app/main.ts` boots the connector twice and installs every page route\r\n * twice. That surfaces at PRODUCTION boot as `Route name \"...\" is already\r\n * taken`, because pages and API routes share one route-name namespace.\r\n *\r\n * The config array is the half to prefer: `warlock build` reads the same array\r\n * to drain each connector's build contribution, so \"built for\" and \"boots with\"\r\n * cannot drift.\r\n *\r\n * String surgery rather than a TypeScript parse: `warlock.config.ts` is an\r\n * app-owned file that may carry any formatting, and a parse-and-print would\r\n * reformat the parts we did not come to change.\r\n */\r\nasync function registerWebConnector(): Promise<void> {\r\n const configPath = rootPath(\"warlock.config.ts\");\r\n\r\n if (!(await fileExistsAsync(configPath))) {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} not found — add this yourself:\\n` +\r\n ` import { webConnector } from \"@warlock.js/web/connector\";\\n` +\r\n ` export default defineConfig({ connectors: [webConnector()] });`,\r\n );\r\n\r\n return;\r\n }\r\n\r\n const current = await getFileAsync(configPath);\r\n\r\n if (current.includes(\"webConnector\")) {\r\n console.log(`${colors.yellowBright(\"webConnector\")} already registered, skipping...`);\r\n\r\n return;\r\n }\r\n\r\n const importLine = 'import { webConnector } from \"@warlock.js/web/connector\";';\r\n let next = current.includes(importLine) ? current : `${importLine}\\n${current}`;\r\n\r\n // An existing `connectors: [` gains one entry; otherwise the key is added to\r\n // the object `defineConfig` receives.\r\n if (/connectors:\\s*\\[/.test(next)) {\r\n next = next.replace(/connectors:\\s*\\[/, \"connectors: [webConnector(),\");\r\n } else if (next.includes(\"defineConfig({\")) {\r\n next = next.replace(\"defineConfig({\", \"defineConfig({\\n connectors: [webConnector()],\");\r\n } else {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} has no recognisable defineConfig({...}) — ` +\r\n \"add `connectors: [webConnector()]` yourself.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n await putFileAsync(configPath, next);\r\n console.log(`${colors.green(\"✓\")} Registered webConnector in warlock.config.ts`);\r\n}\r\n\r\n/**\r\n * The app routes file the project template registers `GET /` in. Only this one\r\n * path is inspected: `warlock add web` is not a codebase-wide route auditor, and\r\n * a project that keeps its routes elsewhere lands on the `absent` outcome below,\r\n * which writes the page exactly as before.\r\n */\r\nconst APP_ROUTES_FILE = \"app/shared/routes.ts\";\r\n\r\n/**\r\n * A TOP-LEVEL `router.get(\"/\", ...)` — anchored at column 0 on purpose.\r\n *\r\n * Routes nested in a `router.group({ prefix: \"/x\" }, ...)` are indented by every\r\n * formatter this codebase runs, and their real path is `/x`, not `/`. Anchoring\r\n * is what keeps the notifications feature's own `router.get(\"/\", ...)` (inside\r\n * the `/notifications` group) from reading as a homepage collision.\r\n *\r\n * Only the path literal is captured. The handler — a bare identifier in the\r\n * template, but possibly an inline arrow spanning lines — is never matched, so\r\n * the rewrite below cannot damage it.\r\n */\r\nconst TOP_LEVEL_ROOT_GET = /^router\\s*\\.\\s*get\\(\\s*([\"'`])\\/\\1/gm;\r\n\r\n/**\r\n * Whether `/welcome` is already spoken for, so relocating onto it would trade\r\n * one duplicate-route 500 for another.\r\n */\r\nconst TOP_LEVEL_WELCOME_GET = /^router\\s*\\.\\s*get\\(\\s*([\"'`])\\/welcome\\1/m;\r\n\r\ntype HomeRouteCollision =\r\n /** No app routes file, or nothing claims `/` — write the page as normal. */\r\n | { outcome: \"absent\" }\r\n /** The template's `GET /` was moved to `/welcome`; the page is safe to write. */\r\n | { outcome: \"relocated\" }\r\n /** Something claims `/` that we will not rewrite. The page is NOT written. */\r\n | { outcome: \"conflict\"; reason: string }\r\n /** We tried to relocate and could not. The page is NOT written. */\r\n | { outcome: \"failed\"; reason: string };\r\n\r\n/**\r\n * Make room for a page that declares `route.path = \"/\"`.\n *\r\n * The project template registers `router.get(\"/\", homePageController)` and the\r\n * page stub declares `route.path = \"/\"`. Fastify rejects the second registration\n * (`Method 'GET' already declared for route '/'`) and the homepage 500s at\r\n * request time — so `warlock add web` cannot just write the page and hope.\r\n *\r\n * Of the three ways out, this RELOCATES the JSON route to `/welcome` rather than\r\n * deleting it or refusing to scaffold:\r\n *\r\n * - Deleting the controller is what the scaffolder's own `react` feature does,\r\n * but it may do that: it owns the file it is deleting, seconds after writing\r\n * it. `warlock add web` runs against a project a human has been living in, and\r\n * silently unlinking their code is not a thing an `add` command gets to do.\r\n * - Writing the page anyway and printing a warning ships a project whose\r\n * homepage 500s. A warning above a broken app is still a broken app.\r\n * - Relocating keeps BOTH surfaces working: the React homepage takes `/`, the\r\n * JSON welcome answers at `/welcome`, and no line of user code disappears.\r\n *\r\n * Only the exact top-level shape is rewritten, and only the path literal inside\r\n * it. Anything else that claims `/` is reported and left completely alone — we\r\n * do not guess at code we cannot recognise.\r\n */\r\nasync function relocateConflictingHomeRoute(): Promise<HomeRouteCollision> {\r\n const routesPath = srcPath(APP_ROUTES_FILE);\r\n\r\n // Not every project comes from the template. No file is not a problem.\r\n if (!(await fileExistsAsync(routesPath))) {\r\n return { outcome: \"absent\" };\r\n }\r\n\r\n let current: string;\r\n\r\n try {\r\n current = await getFileAsync(routesPath);\r\n } catch (error) {\r\n return {\r\n outcome: \"failed\",\r\n reason: `could not be read (${(error as Error).message})`,\r\n };\r\n }\r\n\r\n const matches = current.match(TOP_LEVEL_ROOT_GET) ?? [];\r\n\r\n if (matches.length === 0) {\r\n return { outcome: \"absent\" };\r\n }\r\n\r\n if (matches.length > 1) {\r\n return {\r\n outcome: \"conflict\",\r\n reason: `declares ${matches.length} top-level GET \"/\" routes`,\r\n };\r\n }\r\n\r\n if (TOP_LEVEL_WELCOME_GET.test(current)) {\r\n return {\r\n outcome: \"conflict\",\r\n reason: 'already declares GET \"/welcome\", so the usual relocation target is taken',\r\n };\r\n }\r\n\r\n const next = current.replace(TOP_LEVEL_ROOT_GET, (match, quote: string) =>\r\n match.replace(`${quote}/${quote}`, `${quote}/welcome${quote}`),\r\n );\r\n\r\n if (next === current) {\r\n return { outcome: \"conflict\", reason: 'its GET \"/\" route could not be rewritten' };\r\n }\r\n\r\n try {\r\n await putFileAsync(routesPath, next);\r\n } catch (error) {\r\n return {\r\n outcome: \"failed\",\r\n reason: `could not be written (${(error as Error).message})`,\r\n };\r\n }\r\n\r\n return { outcome: \"relocated\" };\r\n}\r\n\r\n/**\r\n * Scaffold the smallest page layer that renders, and register the connector.\r\n *\r\n * `src/web/root.tsx` is the sentinel for \"already scaffolded\" — the framework\r\n * ships a default root, so its presence means a human has been here.\r\n */\r\nasync function completeWebInstallation(_options: CommandActionData) {\r\n const rootFile = srcPath(\"web/root.tsx\");\r\n\r\n if (await fileExistsAsync(rootFile)) {\r\n console.log(`${colors.yellowBright(\"src/web\")} already scaffolded, skipping...`);\r\n } else {\r\n await ensureDirectoryAsync(srcPath(\"web\"));\r\n await putFileAsync(rootFile, webRootStub);\r\n console.log(`${colors.green(\"✓\")} Created src/web/root.tsx`);\r\n\r\n const collision = await relocateConflictingHomeRoute();\r\n\r\n if (collision.outcome === \"relocated\") {\r\n console.log(\r\n `${colors.green(\"✓\")} Moved the existing ${colors.yellowBright('GET \"/\"')} route to ` +\r\n `${colors.yellowBright('\"/welcome\"')} in ${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} — ` +\r\n \"the new page owns `/` now, and the JSON welcome route still answers at /welcome.\",\r\n );\r\n }\r\n\r\n // The page is written ONLY when `/` is provably free. Writing it while\r\n // another handler holds `/` produces a homepage that 500s on first request,\r\n // which is precisely the outcome a scaffolder must never hand back.\r\n if (collision.outcome === \"conflict\" || collision.outcome === \"failed\") {\r\n const verb = collision.outcome === \"failed\" ? colors.redBright(\"✗\") : colors.yellowBright(\"!\");\r\n\r\n console.log(\r\n `${verb} Did not create src/web/index.page.tsx: ` +\n `${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} ${collision.reason}.\\n` +\r\n ` The page stub declares ${colors.yellowBright('route.path = \"/\"')}, and two handlers on one ` +\n \"path is a 500 at request time, not a startup error.\\n\" +\r\n ` Free up ${colors.yellowBright('GET \"/\"')} in that file — move it to a path of its own, ` +\r\n \"or remove it — then create src/web/index.page.tsx yourself. Giving the page a `route` other \" +\n \"than `/` works too.\",\r\n );\r\n\r\n // Non-zero on BOTH branches. The page layer this command exists to\r\n // scaffold was not scaffolded, and a 0 here is the exact \"looked like it\r\n // worked\" signal that put `/` in this state to begin with — a conflict we\r\n // declined to guess at is still an incomplete install, not a success.\r\n //\r\n // `exitCode` rather than `exit(1)`: the connector below still has to be\r\n // registered, and any other feature in the same `warlock add` invocation\r\n // still has to install, or the project is left half-wired on top of this.\r\n process.exitCode = 1;\r\n } else {\n await putFileAsync(srcPath(\"web/index.page.tsx\"), webHomePageStub);\n await putFileAsync(srcPath(\"web/index.register.ts\"), webHomeRegisterStub);\n await ensureDirectoryAsync(srcPath(\"app/contact/controllers\"));\n await putFileAsync(\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\n webContactControllerStub,\n );\n await putFileAsync(srcPath(\"app/contact/routes.ts\"), webContactRoutesStub);\n console.log(`${colors.green(\"✓\")} Created src/web/index.page.tsx`);\n console.log(`${colors.green(\"✓\")} Created POST /api/contact starter route`);\n }\r\n }\r\n\r\n await registerWebConnector();\r\n}\r\n\r\nexport const webFeature: FeatureDefinition = {\r\n description:\r\n \"Installs @warlock.js/web — SSR React pages served by the Warlock HTTP server. Scaffolds src/web (root.tsx + a home page) and registers the WebConnector in warlock.config.ts. Pages are opt-in: a Warlock app is an API until you add this.\",\r\n dependencies: {\r\n \"@warlock.js/web\": INSTALLED_WARLOCK_VERSION,\n \"@mongez/http\": \"^3.5.0\",\n \"@mongez/react-form\": \"^4.0.0\",\n \"@mongez/react-localization\": \"^3.4.7\",\n react: \"^19.2.3\",\r\n \"react-dom\": \"^19.2.3\",\r\n },\r\n devDependencies: {\r\n \"@types/react\": \"^19.2.7\",\r\n \"@types/react-dom\": \"^19.2.3\",\r\n // Loaded through `await import()` by the dev server only, so both are\r\n // optional peers of `web` rather than hard dependencies.\r\n vite: \"^7.3.5\",\r\n \"@vitejs/plugin-react\": \"^5.2.0\",\r\n },\r\n onExecuting: completeWebInstallation,\r\n};\r\n"],"mappings":"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"}
1
+ {"version":3,"file":"web.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/web.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { ensureDirectoryAsync, fileExistsAsync, getFileAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport { relocateConflictingHomeRoute } from \"./shared/relocate-conflicting-home-route\";\r\nimport { resolveContactScaffold } from \"./shared/resolve-contact-scaffold\";\r\nimport {\r\n webContactControllerStub,\r\n webContactRoutesStub,\r\n webHomePageStub,\r\n webHomeRegisterStub,\r\n webRootStub,\r\n} from \"../stubs\";\r\nimport { type FeatureDefinition, INSTALLED_WARLOCK_VERSION } from \"./types\";\r\n\r\n/**\r\n * Register the WebConnector in `warlock.config.ts`, and ONLY there.\r\n *\r\n * It belongs to the config array or to app code, never both. Both halves are\r\n * registered before app code loads — the CLI preloader in dev, the generated\r\n * entry in production — so also calling `connectorsManager.register(...)` in\r\n * `src/app/main.ts` boots the connector twice and installs every page route\r\n * twice. That surfaces at PRODUCTION boot as `Route name \"...\" is already\r\n * taken`, because pages and API routes share one route-name namespace.\r\n *\r\n * The config array is the half to prefer: `warlock build` reads the same array\r\n * to drain each connector's build contribution, so \"built for\" and \"boots with\"\r\n * cannot drift.\r\n *\r\n * String surgery rather than a TypeScript parse: `warlock.config.ts` is an\r\n * app-owned file that may carry any formatting, and a parse-and-print would\r\n * reformat the parts we did not come to change.\r\n */\r\nasync function registerWebConnector(): Promise<void> {\r\n const configPath = rootPath(\"warlock.config.ts\");\r\n\r\n if (!(await fileExistsAsync(configPath))) {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} not found — add this yourself:\\n` +\r\n ` import { webConnector } from \"@warlock.js/web/connector\";\\n` +\r\n ` export default defineConfig({ connectors: [webConnector()] });`,\r\n );\r\n\r\n return;\r\n }\r\n\r\n const current = await getFileAsync(configPath);\r\n\r\n if (current.includes(\"webConnector\")) {\r\n console.log(`${colors.yellowBright(\"webConnector\")} already registered, skipping...`);\r\n\r\n return;\r\n }\r\n\r\n const importLine = 'import { webConnector } from \"@warlock.js/web/connector\";';\r\n let next = current.includes(importLine) ? current : `${importLine}\\n${current}`;\r\n\r\n // An existing `connectors: [` gains one entry; otherwise the key is added to\r\n // the object `defineConfig` receives.\r\n if (/connectors:\\s*\\[/.test(next)) {\r\n next = next.replace(/connectors:\\s*\\[/, \"connectors: [webConnector(),\");\r\n } else if (next.includes(\"defineConfig({\")) {\r\n next = next.replace(\"defineConfig({\", \"defineConfig({\\n connectors: [webConnector()],\");\r\n } else {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} has no recognisable defineConfig({...}) — ` +\r\n \"add `connectors: [webConnector()]` yourself.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n await putFileAsync(configPath, next);\r\n console.log(`${colors.green(\"✓\")} Registered webConnector in warlock.config.ts`);\r\n}\r\n\r\n/**\r\n * Scaffold the smallest page layer that renders, and register the connector.\r\n *\r\n * `src/web/root.tsx` is the sentinel for \"already scaffolded\" — the framework\r\n * ships a default root, so its presence means a human has been here.\r\n */\r\nasync function completeWebInstallation(_options: CommandActionData) {\r\n const rootFile = srcPath(\"web/root.tsx\");\r\n\r\n if (await fileExistsAsync(rootFile)) {\r\n console.log(`${colors.yellowBright(\"src/web\")} already scaffolded, skipping...`);\r\n } else {\r\n await ensureDirectoryAsync(srcPath(\"web\"));\r\n await putFileAsync(rootFile, webRootStub);\r\n console.log(`${colors.green(\"✓\")} Created src/web/root.tsx`);\r\n\r\n const collision = await relocateConflictingHomeRoute();\r\n\r\n if (collision.outcome === \"relocated\") {\r\n console.log(\r\n `${colors.green(\"✓\")} Moved the existing ${colors.yellowBright('GET \"/\"')} route to ` +\r\n `${colors.yellowBright('\"/welcome\"')} in ${colors.yellowBright(`src/${collision.relativePath}`)} — ` +\r\n \"the new page owns `/` now, and the JSON welcome route still answers at /welcome.\",\r\n );\r\n }\r\n\r\n // The page is written ONLY when `/` is provably free. Writing it while\r\n // another handler holds `/` produces a homepage that 500s on first request,\r\n // which is precisely the outcome a scaffolder must never hand back.\r\n if (collision.outcome === \"conflict\" || collision.outcome === \"failed\") {\r\n const verb = collision.outcome === \"failed\" ? colors.redBright(\"✗\") : colors.yellowBright(\"!\");\r\n\r\n console.log(\r\n `${verb} Did not create src/web/index.page.tsx: ${collision.reason}.\\n` +\r\n ` The page stub declares ${colors.yellowBright('route.path = \"/\"')}, and two handlers on one ` +\r\n \"path is a 500 at request time, not a startup error.\\n\" +\r\n ` Free up ${colors.yellowBright('GET \"/\"')} under src/app — move it to a path of its own, ` +\r\n \"or remove it — then create src/web/index.page.tsx yourself. Giving the page a `route` other \" +\r\n \"than `/` works too.\",\r\n );\r\n\r\n // Non-zero on BOTH branches. The page layer this command exists to\r\n // scaffold was not scaffolded, and a 0 here is the exact \"looked like it\r\n // worked\" signal that put `/` in this state to begin with — a conflict we\r\n // declined to guess at is still an incomplete install, not a success.\r\n //\r\n // `exitCode` rather than `exit(1)`: the connector below still has to be\r\n // registered, and any other feature in the same `warlock add` invocation\r\n // still has to install, or the project is left half-wired on top of this.\r\n process.exitCode = 1;\r\n } else {\r\n await putFileAsync(srcPath(\"web/index.page.tsx\"), webHomePageStub);\r\n await putFileAsync(srcPath(\"web/index.register.ts\"), webHomeRegisterStub);\r\n\r\n // Unlike root.tsx above, the contact controller and its routes file are\r\n // ordinary application files a project can already have — independently\r\n // of ever having run `warlock add web`. Each is guarded on its OWN\r\n // existence, not on the (already-consumed) root.tsx sentinel, so an\r\n // existing `contact` module is skipped rather than clobbered. See\r\n // resolveContactScaffold's doc comment for why.\r\n const contactPlan = resolveContactScaffold({\r\n controllerExists: await fileExistsAsync(\r\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\r\n ),\r\n routesExists: await fileExistsAsync(srcPath(\"app/contact/routes.ts\")),\r\n });\r\n\r\n if (contactPlan.writeController) {\r\n await ensureDirectoryAsync(srcPath(\"app/contact/controllers\"));\r\n await putFileAsync(\r\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\r\n webContactControllerStub,\r\n );\r\n }\r\n\r\n if (contactPlan.writeRoutes) {\r\n await ensureDirectoryAsync(srcPath(\"app/contact\"));\r\n await putFileAsync(srcPath(\"app/contact/routes.ts\"), webContactRoutesStub);\r\n }\r\n\r\n console.log(`${colors.green(\"✓\")} Created src/web/index.page.tsx`);\r\n\r\n for (const message of contactPlan.messages) {\r\n console.log(message);\r\n }\r\n }\r\n }\r\n\r\n await registerWebConnector();\r\n}\r\n\r\nexport const webFeature: FeatureDefinition = {\r\n description:\r\n \"Installs @warlock.js/web — SSR React pages served by the Warlock HTTP server. Scaffolds src/web (root.tsx + a home page) and registers the WebConnector in warlock.config.ts. Pages are opt-in: a Warlock app is an API until you add this.\",\r\n dependencies: {\r\n \"@warlock.js/web\": INSTALLED_WARLOCK_VERSION,\r\n \"@mongez/http\": \"^3.5.0\",\r\n \"@mongez/react-form\": \"^4.0.0\",\r\n \"@mongez/react-localization\": \"^3.4.7\",\r\n react: \"^19.2.3\",\r\n \"react-dom\": \"^19.2.3\",\r\n },\r\n devDependencies: {\r\n \"@types/react\": \"^19.2.7\",\r\n \"@types/react-dom\": \"^19.2.3\",\r\n // Loaded through `await import()` by the dev server only, so both are\r\n // optional peers of `web` rather than hard dependencies.\r\n vite: \"^7.3.5\",\r\n \"@vitejs/plugin-react\": \"^5.2.0\",\r\n },\r\n onExecuting: completeWebInstallation,\r\n};\r\n"],"mappings":"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"}
@@ -461,7 +461,7 @@ import { Notification } from "../notification.model";
461
461
  export default Migration.create(Notification, notificationColumns(Notification));
462
462
  `;
463
463
  const notificationControllersStub = `import { type RequestHandler } from "@warlock.js/core";
464
- import { inApp } from "@warlock.js/notifications";
464
+ import { inApp, type Id } from "@warlock.js/notifications";
465
465
 
466
466
  /**
467
467
  * The authenticated user's notification HTTP surface — thin wrappers over the
@@ -470,9 +470,28 @@ import { inApp } from "@warlock.js/notifications";
470
470
  * is no create. Trim or split these as your app grows.
471
471
  */
472
472
 
473
+ /**
474
+ * Read \`id\` off \`request.user\` without assuming this app's \`RequestUser\`
475
+ * augmentation declares it — \`RequestUser\` is empty by default (see
476
+ * \`@warlock.js/core\`'s \`RequestUser\` docs), so a narrow runtime read survives
477
+ * any augmentation shape instead of assuming \`.id\` exists at the type level.
478
+ * \`inApp\` only ever needs the id (it reduces a \`Notifiable\` to one via
479
+ * \`recipient.id\` internally), so reading it here — rather than forwarding
480
+ * \`request.user\` itself — also skips a needless \`Notifiable\` cast.
481
+ */
482
+ function recipientId(user: unknown): Id {
483
+ if (user && typeof user === "object" && "id" in user) {
484
+ const id = (user as { id?: unknown }).id;
485
+
486
+ if (typeof id === "string" || typeof id === "number") return id;
487
+ }
488
+
489
+ throw new Error("Authenticated request is missing a usable user id");
490
+ }
491
+
473
492
  /** GET /notifications — list, most recent first (page / limit / type / unread via query). */
474
493
  export const listNotificationsController: RequestHandler = async ({ request, response }) => {
475
- const { data, pagination } = await inApp.list(request.user!, request.all());
494
+ const { data, pagination } = await inApp.list(recipientId(request.user), request.all());
476
495
 
477
496
  return response.success({ notifications: data, pagination });
478
497
  };
@@ -484,7 +503,7 @@ export const unreadNotificationsCountController: RequestHandler = async ({
484
503
  request,
485
504
  response,
486
505
  }) => {
487
- const count = await inApp.countUnread(request.user!);
506
+ const count = await inApp.countUnread(recipientId(request.user));
488
507
 
489
508
  return response.success({ count });
490
509
  };
@@ -494,9 +513,10 @@ unreadNotificationsCountController.description = "Unread notifications count";
494
513
  /** PATCH /notifications/:id/read — mark one read, return the updated row. */
495
514
  export const markNotificationReadController: RequestHandler = async ({ request, response }) => {
496
515
  const id = request.input("id");
516
+ const userId = recipientId(request.user);
497
517
 
498
- await inApp.markAsRead(request.user!, id);
499
- const notification = await inApp.find(request.user!, id);
518
+ await inApp.markAsRead(userId, id);
519
+ const notification = await inApp.find(userId, id);
500
520
 
501
521
  return response.success({ notification });
502
522
  };
@@ -508,7 +528,7 @@ export const markAllNotificationsReadController: RequestHandler = async ({
508
528
  request,
509
529
  response,
510
530
  }) => {
511
- const count = await inApp.markAsRead(request.user!);
531
+ const count = await inApp.markAsRead(recipientId(request.user));
512
532
 
513
533
  return response.success({ count });
514
534
  };
@@ -517,7 +537,7 @@ markAllNotificationsReadController.description = "Mark all notifications read";
517
537
 
518
538
  /** DELETE /notifications — dismiss all for the user. */
519
539
  export const clearNotificationsController: RequestHandler = async ({ request, response }) => {
520
- await inApp.dismiss(request.user!);
540
+ await inApp.dismiss(recipientId(request.user));
521
541
 
522
542
  return response.noContent();
523
543
  };
@@ -526,7 +546,7 @@ clearNotificationsController.description = "Clear notifications";
526
546
 
527
547
  /** DELETE /notifications/:id — dismiss one. */
528
548
  export const deleteNotificationController: RequestHandler = async ({ request, response }) => {
529
- await inApp.dismiss(request.user!, request.input("id"));
549
+ await inApp.dismiss(recipientId(request.user), request.input("id"));
530
550
 
531
551
  return response.noContent();
532
552
  };
@@ -570,8 +590,8 @@ router.group({ prefix: "/notifications", middleware: [authMiddleware([])] }, ()
570
590
  * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller
571
591
  * shape: middleware, an app-level loader, locales, an ErrorBoundary.
572
592
  */
573
- const webRootStub = `import { Head, Scripts } from "@warlock.js/web";
574
- import type { AppProps } from "@warlock.js/web";
593
+ const webRootStub = `import type { AppProps } from "@warlock.js/web";
594
+ import { Head, Scripts } from "@warlock.js/web";
575
595
 
576
596
  /**
577
597
  * The application root.
@@ -636,7 +656,10 @@ export const contactSchema = v.object({
636
656
  export type ContactSchema = Infer.Output<typeof contactSchema>;
637
657
 
638
658
  /** POST /api/contact — validates the starter contact form. */
639
- export const contactController: RequestHandler<Request<ContactSchema>> = async ({ request, response }) => {
659
+ export const contactController: RequestHandler<Request<ContactSchema>> = async ({
660
+ request,
661
+ response,
662
+ }) => {
640
663
  const contact = request.validated();
641
664
 
642
665
  // Replace this with delivery/persistence for your app. Keeping the accepted
@@ -697,12 +720,12 @@ export function register() {
697
720
  * the moment this finishes.
698
721
  */
699
722
  const webHomePageStub = `import { http } from "@mongez/http";
700
- import { Form, useFormControl, type FormControlProps } from "@mongez/react-form";
701
723
  import { setCurrentLocaleCode } from "@mongez/localization";
724
+ import { Form, useFormControl, type FormControlProps } from "@mongez/react-form";
702
725
  import { transX } from "@mongez/react-localization";
703
726
  import { v } from "@warlock.js/seal";
704
- import { useState } from "react";
705
727
  import { Link, type PageProps } from "@warlock.js/web";
728
+ import { useState } from "react";
706
729
 
707
730
  export { register } from "./index.register";
708
731
 
@@ -818,8 +841,12 @@ export default function HomePage(_props: PageProps) {
818
841
 
819
842
  <main className="wk-home" dir={locale === "ar" ? "rtl" : "ltr"}>
820
843
  <nav className="wk-links" aria-label="Starter links">
821
- <a href="https://warlock.js.org" target="_blank" rel="noreferrer">Docs</a>
822
- <Link href="/" aria-current="page">Home</Link>
844
+ <a href="https://warlock.js.org" target="_blank" rel="noreferrer">
845
+ Docs
846
+ </a>
847
+ <Link href="/" aria-current="page">
848
+ Home
849
+ </Link>
823
850
  <button
824
851
  className="wk-language"
825
852
  type="button"
@@ -836,7 +863,7 @@ export default function HomePage(_props: PageProps) {
836
863
  <section className="wk-check">
837
864
  <label>If this number goes up when you click, React is hydrated:</label>
838
865
  <strong>{count}</strong>
839
- <button type="button" onClick={() => setCount(c => c + 1)}>
866
+ <button type="button" onClick={() => setCount((c) => c + 1)}>
840
867
  Count up
841
868
  </button>
842
869
  </section>
@@ -874,11 +901,24 @@ export default function HomePage(_props: PageProps) {
874
901
  }}
875
902
  >
876
903
  <TextInput name="name" label={transX("starter.name")} autoComplete="name" />
877
- <TextInput name="email" label={transX("starter.email")} type="email" autoComplete="email" />
904
+ <TextInput
905
+ name="email"
906
+ label={transX("starter.email")}
907
+ type="email"
908
+ autoComplete="email"
909
+ />
878
910
  <ContactMessage />
879
911
  <button type="submit">{transX("starter.submit")}</button>
880
- {submitError && <p className="wk-submit-error" role="alert">{submitError}</p>}
881
- {submitted && <p className="wk-success" role="status">{transX("starter.sent")}</p>}
912
+ {submitError && (
913
+ <p className="wk-submit-error" role="alert">
914
+ {submitError}
915
+ </p>
916
+ )}
917
+ {submitted && (
918
+ <p className="wk-success" role="status">
919
+ {transX("starter.sent")}
920
+ </p>
921
+ )}
882
922
  </Form>
883
923
  </section>
884
924
  </main>