@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.
- package/CHANGELOG.md +24 -0
- package/esm/cli/cli-commands.utils.mjs +27 -9
- package/esm/cli/cli-commands.utils.mjs.map +1 -1
- package/esm/dev-server/shortcuts.mjs +27 -6
- package/esm/dev-server/shortcuts.mjs.map +1 -1
- package/esm/generations/features/react-email.feature.mjs +3 -9
- package/esm/generations/features/react-email.feature.mjs.map +1 -1
- package/esm/generations/features/shared/patch-tsconfig-include.mjs +85 -0
- package/esm/generations/features/shared/patch-tsconfig-include.mjs.map +1 -0
- package/esm/generations/features/shared/relocate-conflicting-home-route.mjs +153 -0
- package/esm/generations/features/shared/relocate-conflicting-home-route.mjs.map +1 -0
- package/esm/generations/features/shared/resolve-contact-scaffold.mjs +45 -0
- package/esm/generations/features/shared/resolve-contact-scaffold.mjs.map +1 -0
- package/esm/generations/features/web.feature.mjs +18 -93
- package/esm/generations/features/web.feature.mjs.map +1 -1
- package/esm/generations/stubs.mjs +59 -19
- package/esm/generations/stubs.mjs.map +1 -1
- package/llms-full.txt +60 -60
- package/llms.txt +3 -3
- package/package.json +12 -12
- package/skills/create-controller/SKILL.md +1 -1
- package/skills/create-module/SKILL.md +11 -11
- package/skills/hash-password/SKILL.md +4 -4
- package/skills/run-app/SKILL.md +16 -16
- package/skills/send-mail/SKILL.md +2 -2
- package/skills/use-model-transformers/SKILL.md +2 -2
- package/skills/use-repository/SKILL.md +1 -1
- package/skills/validate-input/SKILL.md +1 -1
- package/skills/warlock-doctor/SKILL.md +3 -3
- package/skills/warlock-routes/SKILL.md +10 -10
- package/skills/write-cli-command/SKILL.md +1 -1
- 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/${
|
|
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: ${
|
|
153
|
-
Free up ${colors.yellowBright("GET \"/\"")}
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
499
|
-
const notification = await inApp.find(
|
|
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
|
|
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 {
|
|
574
|
-
import
|
|
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 ({
|
|
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">
|
|
822
|
-
|
|
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
|
|
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 &&
|
|
881
|
-
|
|
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>
|