netjes 0.0.0-stage → 0.0.1

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 (222) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/bin/netjes.mjs +13 -0
  4. package/cli/agents.mjs +66 -0
  5. package/cli/commands.mjs +467 -0
  6. package/cli/help.mjs +89 -0
  7. package/cli/info.mjs +454 -0
  8. package/cli/main.mjs +167 -0
  9. package/cli/packages.mjs +19 -0
  10. package/cli/process-io.mjs +163 -0
  11. package/cli/routes.mjs +31 -0
  12. package/cli/scaffold.mjs +193 -0
  13. package/cli/suggest.mjs +50 -0
  14. package/cli/terminal.mjs +40 -0
  15. package/dist/actions.d.ts +98 -0
  16. package/dist/actions.d.ts.map +1 -0
  17. package/dist/actions.js +176 -0
  18. package/dist/actions.js.map +1 -0
  19. package/dist/client-entry.d.ts +2 -0
  20. package/dist/client-entry.d.ts.map +1 -0
  21. package/dist/client-entry.js +102 -0
  22. package/dist/client-entry.js.map +1 -0
  23. package/dist/deferred.d.ts +39 -0
  24. package/dist/deferred.d.ts.map +1 -0
  25. package/dist/deferred.js +123 -0
  26. package/dist/deferred.js.map +1 -0
  27. package/dist/define-page.d.ts +15 -0
  28. package/dist/define-page.d.ts.map +1 -0
  29. package/dist/define-page.js +27 -0
  30. package/dist/define-page.js.map +1 -0
  31. package/dist/document.d.ts +18 -0
  32. package/dist/document.d.ts.map +1 -0
  33. package/dist/document.js +16 -0
  34. package/dist/document.js.map +1 -0
  35. package/dist/draft-mode.d.ts +5 -0
  36. package/dist/draft-mode.d.ts.map +1 -0
  37. package/dist/draft-mode.js +6 -0
  38. package/dist/draft-mode.js.map +1 -0
  39. package/dist/dynamic.d.ts +20 -0
  40. package/dist/dynamic.d.ts.map +1 -0
  41. package/dist/dynamic.js +37 -0
  42. package/dist/dynamic.js.map +1 -0
  43. package/dist/form-data.d.ts +10 -0
  44. package/dist/form-data.d.ts.map +1 -0
  45. package/dist/form-data.js +10 -0
  46. package/dist/form-data.js.map +1 -0
  47. package/dist/form.d.ts +35 -0
  48. package/dist/form.d.ts.map +1 -0
  49. package/dist/form.js +80 -0
  50. package/dist/form.js.map +1 -0
  51. package/dist/href.d.ts +25 -0
  52. package/dist/href.d.ts.map +1 -0
  53. package/dist/href.js +71 -0
  54. package/dist/href.js.map +1 -0
  55. package/dist/image.d.ts +36 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +60 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +18 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +14 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/link.d.ts +17 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +46 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/locale.d.ts +3 -0
  68. package/dist/locale.d.ts.map +1 -0
  69. package/dist/locale.js +6 -0
  70. package/dist/locale.js.map +1 -0
  71. package/dist/metadata.d.ts +83 -0
  72. package/dist/metadata.d.ts.map +1 -0
  73. package/dist/metadata.js +100 -0
  74. package/dist/metadata.js.map +1 -0
  75. package/dist/modules.d.ts +74 -0
  76. package/dist/modules.d.ts.map +1 -0
  77. package/dist/modules.js +85 -0
  78. package/dist/modules.js.map +1 -0
  79. package/dist/nonce.d.ts +8 -0
  80. package/dist/nonce.d.ts.map +1 -0
  81. package/dist/nonce.js +12 -0
  82. package/dist/nonce.js.map +1 -0
  83. package/dist/payload.d.ts +169 -0
  84. package/dist/payload.d.ts.map +1 -0
  85. package/dist/payload.js +271 -0
  86. package/dist/payload.js.map +1 -0
  87. package/dist/public-config.d.ts +11 -0
  88. package/dist/public-config.d.ts.map +1 -0
  89. package/dist/public-config.js +10 -0
  90. package/dist/public-config.js.map +1 -0
  91. package/dist/query.d.ts +22 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +68 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/render-cleanup.d.ts +6 -0
  96. package/dist/render-cleanup.d.ts.map +1 -0
  97. package/dist/render-cleanup.js +7 -0
  98. package/dist/render-cleanup.js.map +1 -0
  99. package/dist/root.d.ts +12 -0
  100. package/dist/root.d.ts.map +1 -0
  101. package/dist/root.js +26 -0
  102. package/dist/root.js.map +1 -0
  103. package/dist/route-tree.d.ts +56 -0
  104. package/dist/route-tree.d.ts.map +1 -0
  105. package/dist/route-tree.js +86 -0
  106. package/dist/route-tree.js.map +1 -0
  107. package/dist/router.d.ts +118 -0
  108. package/dist/router.d.ts.map +1 -0
  109. package/dist/router.js +938 -0
  110. package/dist/router.js.map +1 -0
  111. package/dist/schema.d.ts +35 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +74 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/script.d.ts +23 -0
  116. package/dist/script.d.ts.map +1 -0
  117. package/dist/script.js +89 -0
  118. package/dist/script.js.map +1 -0
  119. package/dist/server-entry.d.ts +2 -0
  120. package/dist/server-entry.d.ts.map +1 -0
  121. package/dist/server-entry.js +4 -0
  122. package/dist/server-entry.js.map +1 -0
  123. package/dist/server-render.d.ts +19 -0
  124. package/dist/server-render.d.ts.map +1 -0
  125. package/dist/server-render.js +193 -0
  126. package/dist/server-render.js.map +1 -0
  127. package/dist/testing-setup.d.ts +2 -0
  128. package/dist/testing-setup.d.ts.map +1 -0
  129. package/dist/testing-setup.js +8 -0
  130. package/dist/testing-setup.js.map +1 -0
  131. package/dist/testing.d.ts +76 -0
  132. package/dist/testing.d.ts.map +1 -0
  133. package/dist/testing.js +106 -0
  134. package/dist/testing.js.map +1 -0
  135. package/dist/vitals-config.d.ts +4 -0
  136. package/dist/vitals-config.d.ts.map +1 -0
  137. package/dist/vitals-config.js +5 -0
  138. package/dist/vitals-config.js.map +1 -0
  139. package/dist/vitals.d.ts +13 -0
  140. package/dist/vitals.d.ts.map +1 -0
  141. package/dist/vitals.js +60 -0
  142. package/dist/vitals.js.map +1 -0
  143. package/dist/zod.d.ts +19 -0
  144. package/dist/zod.d.ts.map +1 -0
  145. package/dist/zod.js +65 -0
  146. package/dist/zod.js.map +1 -0
  147. package/package.json +147 -4
  148. package/sidecar/server.mjs +325 -0
  149. package/skills/netjes/SKILL.md +93 -0
  150. package/skills/netjes/references/caching-and-streaming.md +68 -0
  151. package/skills/netjes/references/data-and-actions.md +98 -0
  152. package/skills/netjes/references/deployment.md +57 -0
  153. package/skills/netjes/references/routing.md +65 -0
  154. package/skills/netjes/references/testing.md +57 -0
  155. package/src/actions.tsx +241 -0
  156. package/src/client-entry.tsx +103 -0
  157. package/src/deferred.ts +134 -0
  158. package/src/define-page.tsx +41 -0
  159. package/src/document.tsx +47 -0
  160. package/src/draft-mode.ts +6 -0
  161. package/src/dynamic.tsx +64 -0
  162. package/src/form-data.ts +10 -0
  163. package/src/form.tsx +122 -0
  164. package/src/href.ts +79 -0
  165. package/src/image.tsx +91 -0
  166. package/src/index.ts +40 -0
  167. package/src/link.tsx +73 -0
  168. package/src/locale.ts +6 -0
  169. package/src/metadata.tsx +181 -0
  170. package/src/modules.ts +133 -0
  171. package/src/nonce.tsx +14 -0
  172. package/src/payload.ts +403 -0
  173. package/src/public-config.tsx +19 -0
  174. package/src/query.tsx +82 -0
  175. package/src/render-cleanup.ts +7 -0
  176. package/src/root.tsx +39 -0
  177. package/src/route-tree.tsx +139 -0
  178. package/src/router.tsx +1188 -0
  179. package/src/schema.ts +98 -0
  180. package/src/script.tsx +103 -0
  181. package/src/server-entry.tsx +4 -0
  182. package/src/server-render.tsx +223 -0
  183. package/src/testing-setup.ts +8 -0
  184. package/src/testing.tsx +177 -0
  185. package/src/virtual.d.ts +12 -0
  186. package/src/vitals-config.ts +6 -0
  187. package/src/vitals.tsx +68 -0
  188. package/src/zod.ts +72 -0
  189. package/template/.dockerignore +11 -0
  190. package/template/Dockerfile +58 -0
  191. package/template/Features/Home/HomePage.cs +32 -0
  192. package/template/Program.cs +28 -0
  193. package/template/Properties/launchSettings.json +13 -0
  194. package/template/README.md +24 -0
  195. package/template/SiteProps.cs +4 -0
  196. package/template/_AGENTS.md +64 -0
  197. package/template/_CLAUDE.md +1 -0
  198. package/template/__NAME__.csproj +11 -0
  199. package/template/_gitattributes +2 -0
  200. package/template/_gitignore +11 -0
  201. package/template/app/about/page.tsx +17 -0
  202. package/template/app/error.tsx +15 -0
  203. package/template/app/layout.tsx +33 -0
  204. package/template/app/not-found.tsx +14 -0
  205. package/template/app/page.tsx +14 -0
  206. package/template/appsettings.Development.json +3 -0
  207. package/template/appsettings.json +9 -0
  208. package/template/components/Greeting.test.tsx +25 -0
  209. package/template/components/Greeting.tsx +26 -0
  210. package/template/components/HelloForm.test.tsx +27 -0
  211. package/template/components/HelloForm.tsx +34 -0
  212. package/template/docker-compose.override.yml +15 -0
  213. package/template/docker-compose.prod.yml +26 -0
  214. package/template/docker-compose.yml +16 -0
  215. package/template/generated/netjes.ts +73 -0
  216. package/template/package.json +36 -0
  217. package/template/public/favicon.svg +6 -0
  218. package/template/styles/global.css +7 -0
  219. package/template/tsconfig.json +19 -0
  220. package/template/vite.config.ts +7 -0
  221. package/vite/plugin.d.ts +29 -0
  222. package/vite/plugin.mjs +291 -0
package/cli/help.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // @ts-check
2
+ // Help text, built from the command table so it cannot drift from what the parser accepts.
3
+
4
+ /**
5
+ * @typedef {import('./commands.mjs').Command} Command
6
+ * @typedef {import('./commands.mjs').OptionSpec} OptionSpec
7
+ */
8
+
9
+ export const DOCS_URL = 'https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/cli.md';
10
+
11
+ /**
12
+ * Options every command takes.
13
+ * @type {{ cwd: OptionSpec; help: OptionSpec; version: OptionSpec }}
14
+ */
15
+ export const GLOBAL_OPTIONS = {
16
+ cwd: { type: 'string', value: '<dir>', description: 'Run in another directory', default: 'the current directory' },
17
+ help: { type: 'boolean', short: 'h', description: 'Show help' },
18
+ version: { type: 'boolean', short: 'v', description: 'Print the netjes version' },
19
+ };
20
+
21
+ /** @param {string} name @param {OptionSpec} option */
22
+ const label = (name, option) => `${option.short ? `-${option.short}, ` : ''}--${name}${option.value ? ` ${option.value}` : ''}`;
23
+
24
+ /**
25
+ * The description, then the default as a sentence of its own: descriptions carry parentheses themselves, and a default
26
+ * in parentheses after them would read as two in a row.
27
+ * @param {{ description: string; default?: string }} option
28
+ */
29
+ const describe = (option) => `${option.description}${option.default ? `. Default: ${option.default}` : ''}`;
30
+
31
+ /** @param {[string, string][]} rows */
32
+ function columns(rows) {
33
+ const width = Math.max(14, ...rows.map(([left]) => left.length)) + 2;
34
+ return rows.map(([left, right]) => ` ${left.padEnd(width)}${right}`.trimEnd()).join('\n');
35
+ }
36
+
37
+ /** @param {string} name @param {Command} command */
38
+ const usage = (name, command) => `netjes ${name}${command.args ? ` ${command.args}` : ''}`;
39
+
40
+ /**
41
+ * The overview: every command and the global options.
42
+ * @param {Record<string, Command>} commands
43
+ * @param {string} version
44
+ */
45
+ export function overviewHelp(commands, version) {
46
+ return [
47
+ `netjes ${version}: React apps and sites on ASP.NET Core`,
48
+ '',
49
+ 'Usage: netjes <command> [options]',
50
+ '',
51
+ 'Commands:',
52
+ columns(Object.entries(commands).map(([name, command]) => [usage(name, command).slice('netjes '.length), command.summary])),
53
+ '',
54
+ 'Options:',
55
+ columns(Object.entries(GLOBAL_OPTIONS).map(([name, option]) => [label(name, option), describe(option)])),
56
+ '',
57
+ 'Run `netjes help <command>` for its options and examples.',
58
+ `Docs: ${DOCS_URL}`,
59
+ '',
60
+ ].join('\n');
61
+ }
62
+
63
+ /**
64
+ * One command: its usage, arguments, options with their defaults, and examples.
65
+ * @param {string} name
66
+ * @param {Command} command
67
+ */
68
+ export function commandHelp(name, command) {
69
+ const options = { ...command.options, cwd: GLOBAL_OPTIONS.cwd };
70
+ return [
71
+ command.summary,
72
+ ...(command.details ? ['', command.details] : []),
73
+ '',
74
+ `Usage: ${usage(name, command)} [options]`,
75
+ ...(command.arguments
76
+ ? ['', 'Arguments:', columns(Object.entries(command.arguments).map(([arg, spec]) => [arg, describe(spec)]))]
77
+ : []),
78
+ '',
79
+ 'Options:',
80
+ columns([
81
+ ...Object.entries(options).map(([option, spec]) => /** @type {[string, string]} */ ([label(option, spec), describe(spec)])),
82
+ [label('help', GLOBAL_OPTIONS.help), 'Show this help'],
83
+ ]),
84
+ '',
85
+ 'Examples:',
86
+ ...command.examples.map((example) => ` ${example}`),
87
+ '',
88
+ ].join('\n');
89
+ }
package/cli/info.mjs ADDED
@@ -0,0 +1,454 @@
1
+ // @ts-check
2
+ // netjes info: the tools and the project setup, one line each, with how to fix each problem it finds.
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import { readFile, readdir, stat } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { findPackage } from './packages.mjs';
7
+ import { paint } from './terminal.mjs';
8
+
9
+ /**
10
+ * ok and warn lines leave the exit code at 0; a fail line makes it 1. warn and fail lines say how to fix them.
11
+ * @typedef {{ status: 'ok' | 'warn' | 'fail'; text: string; fix?: string }} Check
12
+ * @typedef {import('./main.mjs').CliIo} CliIo
13
+ */
14
+
15
+ /**
16
+ * The Node.js releases Netjes and the app template's tools run on: the intersection of the engines of the package and
17
+ * of everything the template installs (vite, vitest, jsdom and their dependencies). The package's and the template's
18
+ * package.json declare the same range, which a test checks.
19
+ */
20
+ export const NODE_RANGE = '^22.22.2 || ^24.15.0 || >=26.0.0';
21
+ export const NODE_DOWNLOAD = 'https://nodejs.org';
22
+ /** The SDK major version Netjes itself needs (it targets net10.0); an app that targets a later one needs that. */
23
+ export const MIN_DOTNET = 10;
24
+ export const DOTNET_DOWNLOAD = 'https://dotnet.microsoft.com/download';
25
+ /** The npm packages a Netjes app runs on. */
26
+ const PACKAGES = ['netjes', 'vite', 'react', 'react-dom'];
27
+ /** Folders that hold no C# source of the app's own. */
28
+ const SKIPPED = new Set(['bin', 'obj', 'node_modules', 'dist', 'out']);
29
+ /** Files written within this long of each other, as a checkout or `netjes new` does, count as written together. */
30
+ const SAME_TIME_MS = 2000;
31
+ /** The Next.js pages router's special files, at the root of pages/, by the App Router file that takes their place. */
32
+ const SPECIAL_FILES = new Map([
33
+ ['_app', 'layout'],
34
+ ['_document', 'layout'],
35
+ ['_error', 'error'],
36
+ ['404', 'not-found'],
37
+ ['500', 'error'],
38
+ ]);
39
+ /** How many files a check names before it counts the rest, besides the special files, which it always names. */
40
+ const LISTED = 3;
41
+
42
+ /** @param {string} text @returns {Check} */
43
+ const ok = (text) => ({ status: 'ok', text });
44
+ /** @param {string} text @param {string} fix @returns {Check} */
45
+ const fail = (text, fix) => ({ status: 'fail', text, fix });
46
+ /** @param {string} text @param {string} fix @returns {Check} */
47
+ const warn = (text, fix) => ({ status: 'warn', text, fix });
48
+
49
+ /** @param {string | undefined} version @returns {[number, number, number] | null} */
50
+ function parseVersion(version) {
51
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version ?? '');
52
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
53
+ }
54
+
55
+ /** @param {[number, number, number]} a @param {[number, number, number]} b */
56
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
57
+
58
+ /**
59
+ * Whether `version` is in `range`, for the forms package.json ranges usually take (^1.2.3, ~1.2.3, >=1, 1.2.3, *,
60
+ * and || between them). A form it does not know passes: only a definite mismatch is reported.
61
+ * @param {string | undefined} version
62
+ * @param {string} range
63
+ */
64
+ export function satisfies(version, range) {
65
+ const have = parseVersion(version);
66
+ if (!have) return true;
67
+ return range.split('||').some((part) => {
68
+ const match = /^\s*(\^|~|>=|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/.exec(part);
69
+ if (!match) return true;
70
+ /** @type {[number, number, number]} */
71
+ const want = [Number(match[2]), Number(match[3] ?? 0), Number(match[4] ?? 0)];
72
+ const atLeast = compare(have, want) >= 0;
73
+ switch (match[1]) {
74
+ case '>=':
75
+ return atLeast;
76
+ case '^':
77
+ return atLeast && (want[0] > 0 ? have[0] === want[0] : have[1] === want[1]);
78
+ case '~':
79
+ return atLeast && have[0] === want[0] && have[1] === want[1];
80
+ default:
81
+ return match[3] === undefined ? have[0] === want[0] : compare(have, want) === 0;
82
+ }
83
+ });
84
+ }
85
+
86
+ /** @param {string} file @returns {Promise<any>} */
87
+ async function readJson(file) {
88
+ try {
89
+ return JSON.parse(await readFile(file, 'utf8'));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * @param {string} version
97
+ * @returns {Check}
98
+ */
99
+ export function checkNode(version) {
100
+ if (satisfies(version, NODE_RANGE)) return ok(`Node.js ${version}`);
101
+ const major = parseVersion(version)?.[0] ?? 0;
102
+ const fix = `Install a Node.js release that matches ${NODE_RANGE}: ${NODE_DOWNLOAD}`;
103
+ // The odd release lines between the supported ones (23, 25) are short-lived; they may work, but are not supported.
104
+ return major > 22 && major % 2 === 1
105
+ ? warn(`Node.js ${version} is not a release line Netjes supports (${NODE_RANGE})`, fix)
106
+ : fail(`Node.js ${version} is too old; Netjes needs ${NODE_RANGE}`, fix);
107
+ }
108
+
109
+ /**
110
+ * @typedef {{ file: string; framework: string; major: number }} Target
111
+ * @typedef {{ file: string; version: string }} GlobalJson
112
+ */
113
+
114
+ /**
115
+ * @param {{ code: number; stdout: string; stderr: string } | null} result `dotnet --version` in the app's folder
116
+ * @param {Target | null} target
117
+ * @param {GlobalJson | null} globalJson
118
+ * @returns {Check}
119
+ */
120
+ export function checkDotnet(result, target, globalJson) {
121
+ const needs = Math.max(MIN_DOTNET, target?.major ?? 0);
122
+ const install = `Install the .NET SDK, ${needs}.0 or newer: ${DOTNET_DOWNLOAD}`;
123
+ if (!result || result.code === 127) return fail('.NET SDK not found', install);
124
+ if (result.code !== 0) {
125
+ const reason = `${result.stderr}\n${result.stdout}`.split('\n').map((line) => line.trim()).find(Boolean);
126
+ return fail(
127
+ `.NET SDK: ${reason ?? `dotnet --version exited with code ${result.code}`}`,
128
+ globalJson
129
+ ? `${globalJson.file} asks for SDK ${globalJson.version}: install that, or change the version there (\`dotnet --list-sdks\` shows what is installed)`
130
+ : `Run \`dotnet --info\` to see what is wrong. ${install}`,
131
+ );
132
+ }
133
+ const version = result.stdout.trim();
134
+ const framework = target?.framework ?? `net${needs}.0`;
135
+ if ((parseVersion(version)?.[0] ?? needs) < needs) {
136
+ return fail(`.NET SDK ${version} cannot build ${framework} (${target?.file ?? 'Netjes'}); it needs ${needs}.0 or newer`, install);
137
+ }
138
+ const why = [target ? `${target.file} targets ${target.framework}` : '', globalJson ? `global.json asks for ${globalJson.version}` : '']
139
+ .filter(Boolean)
140
+ .join('; ');
141
+ return ok(`.NET SDK ${version}${why ? ` (${why})` : ''}`);
142
+ }
143
+
144
+ /** @param {string} dir */
145
+ const isDirectory = (dir) => statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true;
146
+
147
+ /**
148
+ * The newest TargetFramework the project builds for.
149
+ * @param {string} file
150
+ * @param {string} text
151
+ * @returns {Target | null}
152
+ */
153
+ function targetOf(file, text) {
154
+ const frameworks = /<TargetFrameworks?>([^<]+)<\/TargetFrameworks?>/i.exec(text)?.[1]?.split(';') ?? [];
155
+ const targets = frameworks
156
+ .map((framework) => ({ framework: framework.trim(), major: Number(/^net(\d+)\.\d+/.exec(framework.trim())?.[1]) }))
157
+ .filter((target) => Number.isFinite(target.major))
158
+ .sort((a, b) => b.major - a.major);
159
+ return targets[0] ? { file, ...targets[0] } : null;
160
+ }
161
+
162
+ /**
163
+ * The global.json that `dotnet` uses in `cwd`: the nearest one in it or above it, with the SDK version it asks for.
164
+ * @param {string} cwd
165
+ * @returns {Promise<GlobalJson | null>}
166
+ */
167
+ async function globalJsonFor(cwd) {
168
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
169
+ const file = path.join(dir, 'global.json');
170
+ if (existsSync(file)) {
171
+ const version = (await readJson(file))?.sdk?.version;
172
+ return typeof version === 'string' ? { file, version } : null;
173
+ }
174
+ if (path.dirname(dir) === dir) return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Node.js and the .NET SDK.
180
+ * @param {CliIo} io
181
+ * @param {string} cwd
182
+ * @param {Record<string, string | undefined>} env
183
+ * @returns {Promise<Check[]>}
184
+ */
185
+ export async function checkTools(io, cwd, env) {
186
+ const csproj = isDirectory(cwd) ? (await readdir(cwd)).find((entry) => entry.endsWith('.csproj')) : undefined;
187
+ const target = csproj ? targetOf(csproj, await readFile(path.join(cwd, csproj), 'utf8')) : null;
188
+ const dotnet = io.capture ? await io.capture({ command: 'dotnet', args: ['--version'], cwd, env }) : null;
189
+ return [checkNode(process.versions.node), checkDotnet(dotnet, target, await globalJsonFor(cwd))];
190
+ }
191
+
192
+ /**
193
+ * The npm packages the app runs on, and whether they are versions this netjes supports.
194
+ * @param {string} cwd
195
+ * @param {string} cliVersion
196
+ * @returns {Promise<{ checks: Check[]; netjes: string | undefined }>}
197
+ */
198
+ async function checkPackages(cwd, cliVersion) {
199
+ const found = await Promise.all(
200
+ PACKAGES.map(async (name) => {
201
+ const manifest = findPackage(cwd, name);
202
+ const json = manifest ? await readJson(manifest) : null;
203
+ return { name, installed: manifest !== null, version: typeof json?.version === 'string' ? json.version : '?', json };
204
+ }),
205
+ );
206
+ const missing = found.filter((p) => !p.installed).map((p) => p.name);
207
+ if (missing.length) {
208
+ return { checks: [fail(`npm packages are not installed: ${missing.join(', ')}`, 'Run `npm install` in the app')], netjes: undefined };
209
+ }
210
+
211
+ /** @type {Check[]} */
212
+ const checks = [ok(`npm packages: ${found.map((p) => `${p.name} ${p.version}`).join(', ')}`)];
213
+ const netjes = found[0];
214
+ /** @type {Record<string, string>} */
215
+ const peers = netjes?.json?.peerDependencies ?? {};
216
+ /** @type {Map<string, typeof found>} */
217
+ const outside = new Map();
218
+ for (const p of found.slice(1)) {
219
+ const range = peers[p.name];
220
+ if (range && !satisfies(p.version, range)) outside.set(range, [...(outside.get(range) ?? []), p]);
221
+ }
222
+ for (const [range, list] of outside) {
223
+ const named = list.map((p) => `${p.name} ${p.version}`).join(' and ');
224
+ checks.push(
225
+ fail(
226
+ `${named} ${list.length > 1 ? 'do' : 'does'} not match what netjes ${netjes?.version} needs (${range})`,
227
+ `Run \`npm install ${list.map((p) => `"${p.name}@${range}"`).join(' ')}\``,
228
+ ),
229
+ );
230
+ }
231
+ if (netjes && netjes.version !== cliVersion) {
232
+ checks.push(warn(`This is netjes ${cliVersion}; the app has netjes ${netjes.version}`, "Run the app's own with `npx netjes info`, or install one version"));
233
+ }
234
+ return { checks, netjes: netjes?.version };
235
+ }
236
+
237
+ /**
238
+ * How the project file references Netjes, and whether that is the version of the npm package.
239
+ * @param {string} cwd
240
+ * @param {string} csproj
241
+ * @param {string} text
242
+ * @param {string | undefined} npmVersion
243
+ * @param {string} cliVersion
244
+ * @returns {Check}
245
+ */
246
+ function checkReference(cwd, csproj, text, npmVersion, cliVersion) {
247
+ const project = /<ProjectReference\s+[^>]*Include="([^"]*Netjes\.csproj)"/i.exec(text)?.[1];
248
+ if (project) {
249
+ const file = path.resolve(cwd, project.replaceAll('\\', '/'));
250
+ return existsSync(file)
251
+ ? ok(`${csproj} references the Netjes checkout at ${file}`)
252
+ : fail(`${csproj} references Netjes at ${file}, which does not exist`, 'Point its ProjectReference (and the Netjes.targets import) at src/Netjes in a Netjes checkout');
253
+ }
254
+ const element = /<PackageReference\s+[^>]*Include="Netjes"[^>]*>/i.exec(text)?.[0];
255
+ if (!element) {
256
+ return warn(
257
+ `${csproj} does not reference the Netjes package`,
258
+ `Add <PackageReference Include="Netjes" Version="[${npmVersion ?? cliVersion}]" />, unless a Directory.Packages.props does`,
259
+ );
260
+ }
261
+ const reference = /\sVersion="([^"]*)"/i.exec(element)?.[1];
262
+ // Only an exact version, 1.2.3 or [1.2.3], is compared: a range, a floating version or an MSBuild property such as
263
+ // $(NetjesVersion) is resolved by NuGet and MSBuild, which this does not repeat.
264
+ const version = /^\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?$/.exec(reference?.trim() ?? '')?.[1];
265
+ const shown = version ?? reference;
266
+ if (!version || !npmVersion) return ok(`${csproj} references Netjes${shown ? ` ${shown}` : ''}`);
267
+ return version === npmVersion
268
+ ? ok(`${csproj} references Netjes ${version}, the version of the npm package`)
269
+ : fail(
270
+ `${csproj} references Netjes ${version}, but node_modules has netjes ${npmVersion}`,
271
+ 'Use one version on both sides (the C# and npm halves speak one protocol): change the PackageReference or package.json',
272
+ );
273
+ }
274
+
275
+ /**
276
+ * The newest .cs file of the app's own, relative to `cwd` with / separators.
277
+ * @param {string} cwd
278
+ * @returns {Promise<{ file: string; mtimeMs: number } | null>}
279
+ */
280
+ async function newestSource(cwd) {
281
+ /** @type {{ file: string; mtimeMs: number } | null} */
282
+ let newest = null;
283
+ /** @param {string} relative */
284
+ async function walk(relative) {
285
+ for (const entry of await readdir(path.join(cwd, relative), { withFileTypes: true })) {
286
+ const file = relative ? `${relative}/${entry.name}` : entry.name;
287
+ if (entry.isDirectory()) {
288
+ if (!SKIPPED.has(entry.name) && !entry.name.startsWith('.')) await walk(file);
289
+ } else if (entry.name.endsWith('.cs')) {
290
+ const { mtimeMs } = await stat(path.join(cwd, file));
291
+ if (!newest || mtimeMs > newest.mtimeMs) newest = { file, mtimeMs };
292
+ }
293
+ }
294
+ }
295
+ await walk('');
296
+ return newest;
297
+ }
298
+
299
+ /** @param {string} cwd */
300
+ async function countPages(cwd) {
301
+ const files = await readdir(path.join(cwd, 'app'), { recursive: true });
302
+ return files.filter((file) => /^page\.(tsx|jsx|ts|js)$/.test(path.basename(file))).length;
303
+ }
304
+
305
+ /**
306
+ * The project setup: what `netjes dev`, `build` and `generate` rely on.
307
+ * @param {string} cwd
308
+ * @param {string} cliVersion
309
+ * @returns {Promise<Check[]>}
310
+ */
311
+ export async function checkProject(cwd, cliVersion) {
312
+ /** @type {Check[]} */
313
+ const checks = [];
314
+ const entries = isDirectory(cwd) ? await readdir(cwd) : [];
315
+
316
+ const hasManifest = entries.includes('package.json');
317
+ const packages = hasManifest ? await checkPackages(cwd, cliVersion) : { checks: [], netjes: undefined };
318
+ checks.push(...packages.checks);
319
+
320
+ const csproj = entries.find((e) => e.endsWith('.csproj'));
321
+ checks.push(
322
+ csproj
323
+ ? checkReference(cwd, csproj, await readFile(path.join(cwd, csproj), 'utf8'), packages.netjes, cliVersion)
324
+ : fail(`No .csproj in ${cwd}`, "Run netjes info in the app's folder or pass --cwd <dir>; `netjes new <dir>` creates an app"),
325
+ );
326
+
327
+ const viteConfig = entries.find((e) => /^vite\.config\.(ts|mts|js|mjs)$/.test(e));
328
+ if (!viteConfig) {
329
+ checks.push(fail('No vite.config.ts', "Add one: export default defineConfig({ plugins: [netjes()] }), with netjes from 'netjes/vite'"));
330
+ } else if (!(await readFile(path.join(cwd, viteConfig), 'utf8')).includes('netjes(')) {
331
+ checks.push(fail(`${viteConfig} does not use netjes() from 'netjes/vite'`, 'Add netjes() to its plugins'));
332
+ } else checks.push(ok(`${viteConfig} uses netjes()`));
333
+
334
+ if (hasManifest) {
335
+ const manifest = await readJson(path.join(cwd, 'package.json'));
336
+ if (!manifest) checks.push(fail('package.json is not valid JSON', 'Fix its syntax; npm and Vite read it'));
337
+ else if (manifest.scripts?.build) checks.push(ok('package.json has a build script (dotnet publish runs it)'));
338
+ else {
339
+ checks.push(
340
+ fail('package.json has no build script; dotnet publish runs `npm run build`', 'Add "build": "vite build && vite build --ssr" to its scripts'),
341
+ );
342
+ }
343
+ } else checks.push(fail('No package.json', 'A Netjes app keeps its npm side next to the .csproj; `netjes new <dir>` shows the setup'));
344
+
345
+ if (isDirectory(path.join(cwd, 'app'))) {
346
+ const pages = await countPages(cwd);
347
+ checks.push(pages ? ok(`app/ has ${pages} page${pages === 1 ? '' : 's'}`) : warn('app/ has no page files', 'Add app/page.tsx: it is the page at /'));
348
+ } else checks.push(fail('No app/ directory', 'Put the pages in app/: app/page.tsx is /, app/about/page.tsx is /about'));
349
+
350
+ // The app refuses to start for these too: Netjes routes app/ only.
351
+ const { moves, apiRoutes } = isDirectory(path.join(cwd, 'pages'))
352
+ ? await pagesRouterFiles(cwd, 'pages')
353
+ : { moves: [], apiRoutes: [] };
354
+ if (moves.length) {
355
+ // Every special file is named, since each has its own App Router file; the pages after them are capped.
356
+ const specials = moves.filter((move) => move.special).length;
357
+ const named = listing(moves.map((move) => `${move.file} to ${move.target}`), ' the same way', specials);
358
+ // Only _app and _document become a layout: what wrapped every page, and the document Netjes renders itself.
359
+ const layout = moves.some((move) => path.basename(move.target, path.extname(move.target)) === 'layout')
360
+ ? '. The root layout wraps every page; Netjes renders <html>, <head> and <body> around it'
361
+ : '';
362
+ checks.push(fail('pages/ has pages router files, but Netjes routes app/ only', `Move them to their App Router names: ${named}${layout}`));
363
+ }
364
+ if (apiRoutes.length) {
365
+ checks.push(
366
+ fail(
367
+ `pages/api/ has API routes (${listing(apiRoutes)}), which Netjes does not serve`,
368
+ 'Map them as C# minimal API endpoints instead, with app.MapGet or app.MapPost',
369
+ ),
370
+ );
371
+ }
372
+
373
+ const types = path.join(cwd, 'generated', 'netjes.ts');
374
+ if (!existsSync(types)) {
375
+ checks.push(fail('generated/netjes.ts is missing', 'Run `netjes generate`; the app also writes it when it starts in Development'));
376
+ } else {
377
+ const newest = csproj ? await newestSource(cwd) : null;
378
+ checks.push(
379
+ newest && newest.mtimeMs > (await stat(types)).mtimeMs + SAME_TIME_MS
380
+ ? warn(
381
+ `generated/netjes.ts is older than ${newest.file}`,
382
+ 'Run `netjes generate` if the C# types changed; `netjes generate --check` tells for sure',
383
+ )
384
+ : ok('generated/netjes.ts is newer than the C# code'),
385
+ );
386
+ }
387
+ return checks;
388
+ }
389
+
390
+ /**
391
+ * What the pages router served below a directory, with paths relative to `cwd`: the .tsx, .jsx and .js pages, each with
392
+ * its App Router file, and the .ts, .tsx, .js and .jsx API routes in api/. Not .ts files outside api/, which hold no JSX
393
+ * and are helpers far more often than pages, nor co-located tests or declaration files, files in _ folders, or _ files
394
+ * other than the special files at the root. The same rule as the app's startup check, and its order: the special files
395
+ * first, then by path.
396
+ * @param {string} cwd
397
+ * @param {string} directory
398
+ */
399
+ async function pagesRouterFiles(cwd, directory) {
400
+ const files = await readdir(path.join(cwd, directory), { recursive: true, withFileTypes: true });
401
+ /** @type {{ file: string; target: string; special: boolean }[]} */
402
+ const moves = [];
403
+ /** @type {string[]} */
404
+ const apiRoutes = [];
405
+ for (const f of files.filter((f) => f.isFile())) {
406
+ const parts = path.relative(path.join(cwd, directory), path.join(f.parentPath, f.name)).split(path.sep);
407
+ const file = [directory, ...parts].join('/');
408
+ const folders = parts.slice(0, -1);
409
+ const extension = path.extname(f.name);
410
+ const name = path.basename(f.name, extension);
411
+ const special = folders.length === 0 && SPECIAL_FILES.has(name);
412
+ if (/\.(test|spec|d)$/.test(name) || (name.startsWith('_') && !special) || folders.some((folder) => folder.startsWith('_'))) {
413
+ continue;
414
+ }
415
+ if (folders[0] === 'api') {
416
+ if (/^\.(ts|tsx|js|jsx)$/i.test(extension)) apiRoutes.push(file);
417
+ } else if (/^\.(tsx|jsx|js)$/i.test(extension)) {
418
+ // about.tsx is about/page.tsx, an index file the page of its folder, a special file the root file in its place.
419
+ const appName = special ? SPECIAL_FILES.get(name) : name === 'index' ? 'page' : `${name}/page`;
420
+ // app/ routes .tsx and .jsx only, so a .js page becomes a .jsx file.
421
+ const appExtension = /^\.js$/i.test(extension) ? `${extension}${extension.endsWith('S') ? 'X' : 'x'}` : extension;
422
+ moves.push({ file, target: ['app', ...folders, `${appName}${appExtension}`].join('/'), special });
423
+ }
424
+ }
425
+ /** @param {string} a @param {string} b */
426
+ const ordinal = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
427
+ return {
428
+ // The special files first: the rest are counted as moving the same way, and they do not.
429
+ moves: moves.sort((a, b) => Number(b.special) - Number(a.special) || ordinal(a.file, b.file)),
430
+ apiRoutes: apiRoutes.sort(ordinal),
431
+ };
432
+ }
433
+
434
+ /**
435
+ * "a, b, c, and 2 more": the first items, and a count of the rest.
436
+ * @param {string[]} items
437
+ * @param {string} [rest] after the count
438
+ * @param {number} [named] how many items to name before the capped ones
439
+ */
440
+ function listing(items, rest = '', named = 0) {
441
+ const listed = named + LISTED;
442
+ return items.slice(0, listed).join(', ') + (items.length > listed ? `, and ${items.length - listed} more${rest}` : '');
443
+ }
444
+
445
+ /**
446
+ * One line per check, marked ✓, ! or ✗, each problem followed by its fix, indented.
447
+ * @param {Check[]} checks
448
+ * @param {boolean} color
449
+ */
450
+ export function formatChecks(checks, color = false) {
451
+ const { green, yellow, red } = paint(color);
452
+ const marks = { ok: green('✓'), warn: yellow('!'), fail: red('✗') };
453
+ return checks.map((check) => `${marks[check.status]} ${check.text}${check.fix ? `\n ${check.fix}` : ''}`).join('\n') + '\n';
454
+ }