netjes 0.0.2 → 0.0.4

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 (160) hide show
  1. package/README.md +13 -11
  2. package/cli/agents.mjs +25 -2
  3. package/cli/commands.mjs +56 -6
  4. package/cli/help.mjs +7 -2
  5. package/cli/info.mjs +275 -33
  6. package/cli/routes.mjs +35 -8
  7. package/cli/scaffold.mjs +31 -4
  8. package/dist/actions.d.ts +7 -2
  9. package/dist/actions.d.ts.map +1 -1
  10. package/dist/actions.js +9 -3
  11. package/dist/actions.js.map +1 -1
  12. package/dist/client-entry.d.ts +1 -0
  13. package/dist/client-entry.d.ts.map +1 -1
  14. package/dist/client-entry.js +19 -2
  15. package/dist/client-entry.js.map +1 -1
  16. package/dist/deferred.d.ts +11 -0
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +15 -1
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/document.d.ts.map +1 -1
  21. package/dist/document.js +10 -1
  22. package/dist/document.js.map +1 -1
  23. package/dist/errors.d.ts +7 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +11 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/form.d.ts +5 -5
  28. package/dist/form.d.ts.map +1 -1
  29. package/dist/form.js +5 -2
  30. package/dist/form.js.map +1 -1
  31. package/dist/href.d.ts +31 -3
  32. package/dist/href.d.ts.map +1 -1
  33. package/dist/href.js +131 -10
  34. package/dist/href.js.map +1 -1
  35. package/dist/image.d.ts +3 -3
  36. package/dist/image.d.ts.map +1 -1
  37. package/dist/image.js.map +1 -1
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/link.d.ts +15 -7
  43. package/dist/link.d.ts.map +1 -1
  44. package/dist/link.js +38 -7
  45. package/dist/link.js.map +1 -1
  46. package/dist/metadata.d.ts +8 -2
  47. package/dist/metadata.d.ts.map +1 -1
  48. package/dist/metadata.js +14 -5
  49. package/dist/metadata.js.map +1 -1
  50. package/dist/modules.d.ts +15 -1
  51. package/dist/modules.d.ts.map +1 -1
  52. package/dist/modules.js.map +1 -1
  53. package/dist/mounted.d.ts +55 -0
  54. package/dist/mounted.d.ts.map +1 -0
  55. package/dist/mounted.js +110 -0
  56. package/dist/mounted.js.map +1 -0
  57. package/dist/navigation-focus.d.ts +38 -0
  58. package/dist/navigation-focus.d.ts.map +1 -0
  59. package/dist/navigation-focus.js +71 -0
  60. package/dist/navigation-focus.js.map +1 -0
  61. package/dist/payload.d.ts +8 -0
  62. package/dist/payload.d.ts.map +1 -1
  63. package/dist/payload.js +28 -0
  64. package/dist/payload.js.map +1 -1
  65. package/dist/query.d.ts +5 -2
  66. package/dist/query.d.ts.map +1 -1
  67. package/dist/query.js +11 -8
  68. package/dist/query.js.map +1 -1
  69. package/dist/render-document.d.ts +74 -0
  70. package/dist/render-document.d.ts.map +1 -0
  71. package/dist/render-document.js +393 -0
  72. package/dist/render-document.js.map +1 -0
  73. package/dist/route-announcer.d.ts +18 -0
  74. package/dist/route-announcer.d.ts.map +1 -0
  75. package/dist/route-announcer.js +58 -0
  76. package/dist/route-announcer.js.map +1 -0
  77. package/dist/route-tree.d.ts +6 -2
  78. package/dist/route-tree.d.ts.map +1 -1
  79. package/dist/route-tree.js +17 -7
  80. package/dist/route-tree.js.map +1 -1
  81. package/dist/router.d.ts +28 -5
  82. package/dist/router.d.ts.map +1 -1
  83. package/dist/router.js +71 -26
  84. package/dist/router.js.map +1 -1
  85. package/dist/script.d.ts +28 -5
  86. package/dist/script.d.ts.map +1 -1
  87. package/dist/script.js +192 -21
  88. package/dist/script.js.map +1 -1
  89. package/dist/server-render.d.ts.map +1 -1
  90. package/dist/server-render.js +4 -1
  91. package/dist/server-render.js.map +1 -1
  92. package/dist/testing-vitest.d.ts +8 -0
  93. package/dist/testing-vitest.d.ts.map +1 -0
  94. package/dist/testing-vitest.js +26 -0
  95. package/dist/testing-vitest.js.map +1 -0
  96. package/dist/testing.d.ts +17 -1
  97. package/dist/testing.d.ts.map +1 -1
  98. package/dist/testing.js +18 -3
  99. package/dist/testing.js.map +1 -1
  100. package/dist/zod-jitless.d.ts +2 -0
  101. package/dist/zod-jitless.d.ts.map +1 -0
  102. package/dist/zod-jitless.js +4 -0
  103. package/dist/zod-jitless.js.map +1 -0
  104. package/package.json +1 -1
  105. package/sidecar/server.mjs +109 -15
  106. package/skills/netjes/SKILL.md +15 -7
  107. package/skills/netjes/references/caching-and-streaming.md +37 -4
  108. package/skills/netjes/references/data-and-actions.md +15 -7
  109. package/skills/netjes/references/deployment.md +7 -3
  110. package/skills/netjes/references/routing.md +32 -9
  111. package/skills/netjes/references/testing.md +23 -7
  112. package/src/actions.tsx +9 -3
  113. package/src/client-entry.tsx +20 -2
  114. package/src/deferred.ts +14 -1
  115. package/src/document.tsx +22 -3
  116. package/src/errors.ts +12 -0
  117. package/src/form.tsx +10 -7
  118. package/src/href.ts +137 -11
  119. package/src/image.tsx +3 -3
  120. package/src/index.ts +19 -1
  121. package/src/link.tsx +47 -13
  122. package/src/metadata.tsx +19 -6
  123. package/src/modules.ts +15 -2
  124. package/src/mounted.tsx +160 -0
  125. package/src/navigation-focus.ts +75 -0
  126. package/src/payload.ts +32 -0
  127. package/src/query.tsx +14 -8
  128. package/src/render-document.tsx +483 -0
  129. package/src/route-announcer.tsx +69 -0
  130. package/src/route-tree.tsx +17 -6
  131. package/src/router.tsx +116 -31
  132. package/src/script.tsx +214 -23
  133. package/src/server-render.tsx +4 -1
  134. package/src/testing-vitest.ts +37 -0
  135. package/src/testing.tsx +38 -5
  136. package/src/virtual.d.ts +8 -1
  137. package/src/zod-jitless.ts +15 -0
  138. package/template/.dockerignore +3 -0
  139. package/template/Dockerfile +11 -1
  140. package/template/README.md +11 -7
  141. package/template/_AGENTS.md +38 -9
  142. package/template/__NAME__.csproj +2 -0
  143. package/template/app/document.test.ts +29 -0
  144. package/template/app/error.tsx +3 -2
  145. package/template/app/layout.tsx +10 -1
  146. package/template/docker-compose.override.yml +4 -3
  147. package/template/docker-compose.prod.yml +2 -1
  148. package/template/e2e/home.spec.ts +32 -0
  149. package/template/generated/netjes.ts +43 -14
  150. package/template/package.json +4 -1
  151. package/template/playwright.config.ts +36 -0
  152. package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
  153. package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
  154. package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
  155. package/template/tsconfig.app.json +20 -0
  156. package/template/tsconfig.json +2 -17
  157. package/template/tsconfig.node.json +8 -0
  158. package/vite/packages.mjs +345 -0
  159. package/vite/plugin.d.ts +16 -1
  160. package/vite/plugin.mjs +224 -34
package/vite/plugin.mjs CHANGED
@@ -1,12 +1,29 @@
1
1
  // @ts-check
2
- import { copyFile, mkdir, readdir } from 'node:fs/promises';
2
+ import { readFileSync } from 'node:fs';
3
+ import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises';
3
4
  import { createRequire } from 'node:module';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import react from '@vitejs/plugin-react';
7
8
  import { normalizePath, searchForWorkspaceRoot } from 'vite';
9
+ import { findPackage } from '../cli/packages.mjs';
10
+ import {
11
+ CONVENTION_EXTENSIONS,
12
+ CONVENTION_FILES,
13
+ commonJsDependencies,
14
+ fileId,
15
+ filePath,
16
+ isConventionFile,
17
+ isPackageName,
18
+ pagePackageFiles,
19
+ resolvePagePackages,
20
+ unsupportedNetjes,
21
+ withPackageStyles,
22
+ } from './packages.mjs';
8
23
 
9
24
  const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
25
+ /** The version of this netjes, the app's: what a page package's peer range has to hold. */
26
+ const VERSION = /** @type {{ version: string }} */ (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))).version;
10
27
  // The compiled modules the package exports (npm run build writes them), so the app runs the code it type-checked, and
11
28
  // gets one copy of each module: the same files the app's own imports of netjes resolve to. Vite names a file by its path
12
29
  // with forward slashes, on Windows too, so these are named that way.
@@ -15,11 +32,20 @@ const SERVER_ENTRY = normalizePath(fileURLToPath(new URL('../dist/server-entry.j
15
32
  const SIDECAR = fileURLToPath(new URL('../sidecar/server.mjs', import.meta.url));
16
33
  const QUERY_MODULE = normalizePath(fileURLToPath(new URL('../dist/query.js', import.meta.url)));
17
34
  const TEST_SETUP = normalizePath(fileURLToPath(new URL('../dist/testing-setup.js', import.meta.url)));
35
+ // What the test harness needs from Vitest: the setup file every app's tests get, after the app's own. Not exported.
36
+ const TEST_VITEST = normalizePath(fileURLToPath(new URL('../dist/testing-vitest.js', import.meta.url)));
18
37
 
19
38
  const PAGES_ID = 'virtual:netjes/pages';
20
39
  const RESOLVED_PAGES_ID = `\0${PAGES_ID}`;
21
40
  const QUERY_ID = 'virtual:netjes/query';
22
41
  const RESOLVED_QUERY_ID = `\0${QUERY_ID}`;
42
+ /**
43
+ * A module @tailwindcss/vite builds, by its rule: a `.css` file, unless it is imported as text, as a URL or as a worker,
44
+ * which by the time a plugin transforms it is a JavaScript module that exports the text.
45
+ * @param {string} id
46
+ */
47
+ const isTailwindStylesheet = (id) =>
48
+ !/[?&](?:worker|sharedworker|raw|url)\b|\?commonjs-proxy/.test(id) && !id.includes('/.vite/') && (id.split('?')[0] ?? id).endsWith('.css');
23
49
  /** The URL the .NET dev asset resolver points browsers at. */
24
50
  const DEV_CLIENT_ENTRY_URL = '/@netjes/client-entry';
25
51
 
@@ -38,10 +64,24 @@ const BACKEND_DIRS = ['bin', 'obj', 'dist/server'];
38
64
  * @param {string} workspaceRoot
39
65
  */
40
66
  function backendDeny(workspaceRoot) {
41
- const root = normalizePath(workspaceRoot).replace(/[()[\]{}!+@*?\\]/g, '\\$&');
42
- return [...BACKEND_FILES, ...BACKEND_DIRS.map((dir) => `${root}/**/${dir}/**`)];
67
+ return [...BACKEND_FILES, ...backendDirs(workspaceRoot)];
43
68
  }
44
69
 
70
+ /**
71
+ * The backend folders below a folder, such as a page package's own `bin/` and `obj/`.
72
+ * @param {string} dir
73
+ */
74
+ function backendDirs(dir) {
75
+ const root = normalizePath(dir).replace(/[()[\]{}!+@*?\\]/g, '\\$&');
76
+ return BACKEND_DIRS.map((folder) => `${root}/**/${folder}/**`);
77
+ }
78
+
79
+ /**
80
+ * A path as a glob that matches it alone: the characters a glob reads, escaped as Vite's glob (tinyglobby) escapes them.
81
+ * @param {string} file With `/` separators.
82
+ */
83
+ const literalGlob = (file) => file.replace(/[()[\]{}*?|]|[!+@](?=\()/g, '\\$&');
84
+
45
85
  /** Loads @tailwindcss/vite only when Tailwind is on, so apps that turn it off need not install it. */
46
86
  async function tailwind() {
47
87
  try {
@@ -72,18 +112,27 @@ function canResolve(root, name) {
72
112
 
73
113
  /**
74
114
  * Vitest settings for component tests, unless the app set them itself: a DOM (when jsdom is installed), Testing
75
- * Library cleanup (when it is installed), and no Playwright specs (e2e/), build output or .NET bin/obj folders.
115
+ * Library cleanup (when it is installed), and no Playwright specs (e2e/), build output or .NET bin/obj folders. Two
116
+ * exceptions, which Vite merges into what the app set: Netjes's own setup file goes into every app's setupFiles,
117
+ * after the app's own, and provide['netjes:query'] holds the app's query defaultOptions, which that file hands to
118
+ * createTestApp. Only that key is returned, so the app's own provide keys are not merged twice. Vitest 5 builds the
119
+ * test options of an inline test project that shares the root's Vite server from the root's options as written, before
120
+ * this hook runs, so such a project gets none of these; with sharedViteServer: false it resolves the config, and this
121
+ * hook, for each project.
76
122
  * @param {Record<string, unknown> | undefined} test
77
123
  * @param {string} root
124
+ * @param {{ defaultOptions?: import('@tanstack/react-query').DefaultOptions } | null} query
78
125
  */
79
- function testDefaults(test = {}, root) {
126
+ function testDefaults(test = {}, root, query) {
80
127
  /** @type {Record<string, unknown>} */
81
128
  const defaults = {};
82
129
  if (test.environment === undefined && canResolve(root, 'jsdom')) defaults.environment = 'jsdom';
83
130
  if (test.exclude === undefined) {
84
131
  defaults.exclude = ['**/node_modules/**', '**/dist/**', '**/bin/**', '**/obj/**', '**/e2e/**', '**/.git/**'];
85
132
  }
86
- if (test.setupFiles === undefined && canResolve(root, '@testing-library/react')) defaults.setupFiles = [TEST_SETUP];
133
+ const cleanup = test.setupFiles === undefined && canResolve(root, '@testing-library/react');
134
+ defaults.setupFiles = cleanup ? [TEST_SETUP, TEST_VITEST] : [TEST_VITEST];
135
+ if (query) defaults.provide = { 'netjes:query': query.defaultOptions ?? {} };
87
136
  return defaults;
88
137
  }
89
138
 
@@ -150,7 +199,11 @@ async function findRouteHandler(root, appDir) {
150
199
  export default function netjes(options = {}) {
151
200
  const appDir = (options.appDir ?? 'app').replace(/^\/+|\/+$/g, '');
152
201
  // Only the convention files are routes; everything else in app/ is colocated code.
153
- const appFiles = `/${appDir}/**/{page,layout,template,loading,error,not-found,forbidden,unauthorized,global-error,default}.{tsx,jsx}`;
202
+ const appFiles = `/${appDir}/**/{${CONVENTION_FILES.join(',')}}.{${CONVENTION_EXTENSIONS.map((ext) => ext.slice(1)).join(',')}}`;
203
+ const packageNames = options.packages ?? [];
204
+ if (packageNames.length > 0 && options.tailwind === false) {
205
+ throw new Error('netjes: page packages style their pages with Tailwind CSS, which netjes({ tailwind: false }) turns off: turn it on, or list no packages');
206
+ }
154
207
  const basePath = (options.basePath ?? '').replace(/\/+$/, '');
155
208
  const query = options.query === false ? null : options.query === true || options.query === undefined ? {} : options.query;
156
209
  // Fail when the config loads, not in the browser.
@@ -163,6 +216,47 @@ export default function netjes(options = {}) {
163
216
  let outDir = '';
164
217
  /** @type {import('vite').ViteDevServer | undefined} */
165
218
  let devServer;
219
+ /** The page packages the app lists, read when the config is. @type {import('./packages.mjs').PagePackage[]} */
220
+ let packages = [];
221
+
222
+ /**
223
+ * Has the next render, and the next page the browser loads, build the page module map again from the files on disk,
224
+ * as Vite does once its watcher reports a page file added or removed.
225
+ */
226
+ function rescanPages() {
227
+ const timestamp = Date.now();
228
+ for (const { moduleGraph } of Object.values(devServer?.environments ?? {})) {
229
+ const pages = moduleGraph.getModuleById(RESOLVED_PAGES_ID);
230
+ if (pages) moduleGraph.invalidateModule(pages, new Set(), timestamp, true);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The listed package a file id is of, with the file's path in its folder of pages.
236
+ * @param {string} id
237
+ */
238
+ function packageFile(id) {
239
+ for (const pkg of packages) {
240
+ const prefix = `${pkg.name}/${pkg.pages}/`;
241
+ if (id.startsWith(prefix)) return { pkg, file: id.slice(prefix.length) };
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * The name of the installed package whose file a file id names, when the app does not list it in netjes({ packages }):
248
+ * the .NET app mounts it, and the page module map does not have it.
249
+ * @param {string} id
250
+ * @returns {string | null}
251
+ */
252
+ function unlistedPackage(id) {
253
+ if (id.startsWith(`${appDir}/`) || packageFile(id)) return null;
254
+ const segments = id.split('/');
255
+ const length = id.startsWith('@') ? 2 : 1;
256
+ if (segments.length <= length) return null;
257
+ const name = segments.slice(0, length).join('/');
258
+ return isPackageName(name) && findPackage(rootDir, name) ? name : null;
259
+ }
166
260
 
167
261
  /** @type {import('vite').Plugin} */
168
262
  const plugin = {
@@ -174,7 +268,14 @@ export default function netjes(options = {}) {
174
268
  const userTest = /** @type {{ test?: Record<string, unknown> }} */ (/** @type {unknown} */ (config)).test;
175
269
  const root = path.resolve(config.root ?? process.cwd());
176
270
  const workspaceRoot = searchForWorkspaceRoot(root);
177
- const test = env.command === 'serve' && options.test !== false ? { test: testDefaults(userTest, root) } : {};
271
+ packages = resolvePagePackages(root, packageNames, appDir);
272
+ // A build stops here, and the development sidecar reports it as Vite failing to start.
273
+ for (const pkg of packages) {
274
+ const unsupported = unsupportedNetjes(pkg, VERSION);
275
+ if (unsupported) throw new Error(`netjes: ${unsupported}`);
276
+ }
277
+ const listed = packages.map((pkg) => pkg.name);
278
+ const test = env.command === 'serve' && options.test !== false ? { test: testDefaults(userTest, root, query) } : {};
178
279
  // Built asset URLs live under the base path; in development the browser loads modules from the Vite server's root.
179
280
  const base = env.command === 'build' && basePath ? { base: `${basePath}/` } : {};
180
281
  return {
@@ -189,21 +290,30 @@ export default function netjes(options = {}) {
189
290
  ...(query ? ['@tanstack/react-query'] : []),
190
291
  // The test setup's cleanup must unmount what the app's tests rendered.
191
292
  ...(env.mode === 'test' ? ['@testing-library/react'] : []),
293
+ // A page package's pages import netjes and zod, which a linked package would otherwise resolve from its own
294
+ // folder: a second copy, or none at all for one linked from outside the workspace.
295
+ ...(packages.length > 0 ? ['netjes', ...(canResolve(root, 'zod') ? ['zod'] : [])] : []),
192
296
  ],
193
297
  },
194
298
  // netjes serves its own modules (client entry, runtime) and, for their source maps, its sources; a linked
195
- // package lives outside the app. The workspace root keeps Vite's default, but only when the app did not narrow
196
- // fs.allow itself: Vite concatenates arrays. The dev root is the .NET content root, so deny the backend's files.
197
- // A configured deny list replaces Vite's defaults, which are repeated here.
299
+ // package lives outside the app, and so does a page package linked from elsewhere. The workspace root keeps
300
+ // Vite's default, but only when the app did not narrow fs.allow itself: Vite concatenates arrays. The dev root is
301
+ // the .NET content root, so deny the backend's files, and a page package's backend folders wherever it is (its
302
+ // C# half's build output, in a checkout linked from outside the workspace). A configured deny list replaces
303
+ // Vite's defaults, which are repeated here.
198
304
  server: {
199
305
  fs: {
200
- allow: [...(config.server?.fs?.allow ? [] : [workspaceRoot]), PACKAGE_ROOT],
201
- deny: [...VITE_DENY, ...backendDeny(workspaceRoot)],
306
+ allow: [...(config.server?.fs?.allow ? [] : [workspaceRoot]), PACKAGE_ROOT, ...packages.map((pkg) => pkg.root)],
307
+ deny: [...VITE_DENY, ...backendDeny(workspaceRoot), ...packages.flatMap((pkg) => backendDirs(pkg.root))],
202
308
  },
203
309
  },
204
310
  optimizeDeps: {
205
- // There is no index.html to scan; start from the pages so dependencies are pre-bundled up front.
206
- entries: [appFiles.slice(1)],
311
+ // There is no index.html to scan; start from the pages so dependencies are pre-bundled up front, a page
312
+ // package's included: Vite globs these from the root, in node_modules and outside the root too.
313
+ entries: [
314
+ appFiles.slice(1),
315
+ ...packages.map((pkg) => `${literalGlob(normalizePath(path.relative(root, `${pkg.root}/${pkg.pages}`)))}/${appFiles.slice(appDir.length + 2)}`),
316
+ ],
207
317
  include: [
208
318
  'react',
209
319
  'react-dom',
@@ -211,14 +321,19 @@ export default function netjes(options = {}) {
211
321
  ...(query ? ['@tanstack/react-query'] : []),
212
322
  // Imported from inside netjes (netjes/vitals), which the dependency scan skips: found late, it would reload the page.
213
323
  ...(canResolve(root, 'web-vitals') ? ['web-vitals'] : []),
324
+ // A page package is not pre-bundled (below), so its CommonJS dependencies are, through it, as Vite asks.
325
+ ...packages.flatMap((pkg) => commonJsDependencies(pkg).map((dependency) => `${pkg.name} > ${dependency}`)),
214
326
  ],
215
327
  // netjes imports the virtual modules below, which pre-bundling cannot resolve, and the plugin loads its query
216
328
  // shell by path: a pre-bundled copy of netjes would hold a second router, whose contexts the app's pages miss.
217
- exclude: ['netjes'],
329
+ // A page package's pages are loaded by path, so a pre-bundled copy of it would be a second one of each of its
330
+ // modules too.
331
+ exclude: ['netjes', ...listed],
218
332
  },
219
333
  // netjes imports the plugin's virtual modules and must share its module instances with the app's pages, so Vite
220
- // processes it in development as well; production bundles everything for the sidecar.
221
- ssr: { noExternal: env.command === 'build' ? true : ['netjes'] },
334
+ // processes it in development as well, and a page package's TypeScript sources with it; production bundles
335
+ // everything for the sidecar.
336
+ ssr: { noExternal: env.command === 'build' ? true : ['netjes', ...listed] },
222
337
  build: isSsrBuild
223
338
  ? {
224
339
  outDir: 'dist/server',
@@ -243,27 +358,52 @@ export default function netjes(options = {}) {
243
358
  // takes only the convention files, so it would leave one out without a word: it stops on it too, saying the same.
244
359
  async buildStart() {
245
360
  if (!isBuild) return;
246
- const handler = await findRouteHandler(rootDir, appDir);
361
+ let handler = await findRouteHandler(rootDir, appDir);
362
+ // A package's, by its file id, as the route table names it.
363
+ for (const pkg of packages) {
364
+ if (handler) break;
365
+ const found = await findRouteHandler(pkg.root, pkg.pages);
366
+ if (found) handler = `${pkg.name}/${found}`;
367
+ }
247
368
  if (handler) {
248
369
  this.error(`'${handler}': route handlers are C# in Netjes. Map a minimal API endpoint instead (app.MapGet, app.MapPost, ...).`);
249
370
  }
250
371
  },
251
372
  configureServer(server) {
252
373
  devServer = server;
374
+ // Vite watches the root only, so a linked package's files, which can change while the app runs, are added. The
375
+ // map imports a package's files one by one, so a page that comes or goes builds it again, as the glob would.
376
+ const linked = packages.filter((pkg) => pkg.linked);
377
+ if (linked.length === 0) return;
378
+ server.watcher.add([...new Set(linked.flatMap((pkg) => [pkg.pages, ...pkg.sources].map((dir) => `${pkg.root}/${dir}`)))]);
379
+ /** @param {string} file */
380
+ const pageAddedOrRemoved = (file) => {
381
+ const name = normalizePath(file);
382
+ if (isConventionFile(path.posix.basename(name)) && linked.some((pkg) => name.startsWith(`${pkg.root}/${pkg.pages}/`))) rescanPages();
383
+ };
384
+ server.watcher.on('add', pageAddedOrRemoved);
385
+ server.watcher.on('unlink', pageAddedOrRemoved);
253
386
  },
254
387
  // For the development sidecar, which finds this plugin in its dev server's config.
255
388
  api: {
256
389
  /**
257
- * Has the next render, and the next page the browser loads, build the page module map again from the files on
258
- * disk, as Vite does once its watcher reports a page file added or removed.
390
+ * The URL the dev server loads a file by, from the file's id in a payload: `/app/...` for the app's, and for a
391
+ * listed package's the URL of its real path (`/node_modules/...` inside the root, `/@fs/...` outside it). Null for
392
+ * a file of an installed package that netjes({ packages }) does not list, which the page module map lacks.
393
+ * @param {string} id
394
+ * @returns {string | null}
259
395
  */
260
- rescanPages() {
261
- const timestamp = Date.now();
262
- for (const { moduleGraph } of Object.values(devServer?.environments ?? {})) {
263
- const pages = moduleGraph.getModuleById(RESOLVED_PAGES_ID);
264
- if (pages) moduleGraph.invalidateModule(pages, new Set(), timestamp, true);
265
- }
396
+ pageUrl(id) {
397
+ const owned = packageFile(id);
398
+ if (!owned) return unlistedPackage(id) ? null : `/${id}`;
399
+ const file = filePath(owned.pkg, owned.file);
400
+ const relative = path.posix.relative(normalizePath(rootDir), file);
401
+ return relative.startsWith('../') || path.isAbsolute(relative) ? `/@fs/${file.replace(/^\//, '')}` : `/${relative}`;
266
402
  },
403
+ unlistedPackage,
404
+ /** The page packages the app lists. */
405
+ packages: () => packages,
406
+ rescanPages,
267
407
  },
268
408
  resolveId(id) {
269
409
  if (id === PAGES_ID) return RESOLVED_PAGES_ID;
@@ -271,21 +411,71 @@ export default function netjes(options = {}) {
271
411
  if (id === DEV_CLIENT_ENTRY_URL) return CLIENT_ENTRY;
272
412
  return undefined;
273
413
  },
274
- load(id) {
414
+ async load(id) {
275
415
  if (id === RESOLVED_QUERY_ID) {
276
416
  if (!query) return 'export const QueryShell = undefined;\n';
277
417
  return `import { createQueryShell } from ${JSON.stringify(QUERY_MODULE)};\nexport const QueryShell = createQueryShell(${queryLiteral});\n`;
278
418
  }
279
419
  if (id !== RESOLVED_PAGES_ID) return undefined;
280
- const glob = JSON.stringify([appFiles]);
281
- return `export const pages = import.meta.glob(${glob});\n`;
420
+ const glob = `import.meta.glob(${JSON.stringify([appFiles])})`;
421
+ // A package's files are imported one by one, by their real paths: a glob leaves out node_modules, and would name a
422
+ // linked package's files by their paths through the link. Listed again each time, as the glob is.
423
+ const imported = (
424
+ await Promise.all(
425
+ packages.map(async (pkg) =>
426
+ (await pagePackageFiles(pkg)).map((file) => `${JSON.stringify(`/${fileId(pkg, file)}`)}: () => import(${JSON.stringify(filePath(pkg, file))})`),
427
+ ),
428
+ )
429
+ ).flat();
430
+ const pages = packages.length === 0 ? glob : `{ ...${glob}, ${imported.join(', ')} }`;
431
+ // The folder the keys start with: renderDocument (netjes/testing) builds routes and segment ids from it.
432
+ return (
433
+ `export const pages = ${pages};\nexport const appDir = ${JSON.stringify(appDir)};\n` +
434
+ `export const packages = ${JSON.stringify(packages.map(({ name, pages }) => ({ name, pages })))};\n`
435
+ );
282
436
  },
283
437
  async writeBundle() {
284
- if (!isSsrBuild) return;
285
- await mkdir(outDir, { recursive: true });
286
- await copyFile(SIDECAR, path.join(outDir, 'netjes-sidecar.mjs'));
438
+ if (isSsrBuild) {
439
+ await mkdir(outDir, { recursive: true });
440
+ await copyFile(SIDECAR, path.join(outDir, 'netjes-sidecar.mjs'));
441
+ return;
442
+ }
443
+ if (packages.length === 0) return;
444
+ // Where the .NET app finds the packages in production, next to the manifest: no node_modules there to read them
445
+ // from, and a manifest key alone does not say which package a file is of.
446
+ const record = {
447
+ packages: await Promise.all(
448
+ packages.map(async (pkg) => ({
449
+ name: pkg.name,
450
+ version: pkg.version,
451
+ netjes: pkg.netjes,
452
+ pages: pkg.pages,
453
+ // Each file's id, and its key in the manifest: its path from the root, as Vite names a chunk's source.
454
+ files: Object.fromEntries((await pagePackageFiles(pkg)).map((file) => [fileId(pkg, file), normalizePath(path.relative(rootDir, filePath(pkg, file)))])),
455
+ // The NuGet package of its C# half, which the app names when the two halves are different versions.
456
+ ...(pkg.nuget === null ? {} : { nuget: pkg.nuget }),
457
+ })),
458
+ ),
459
+ };
460
+ await mkdir(path.join(outDir, '.vite'), { recursive: true });
461
+ await writeFile(path.join(outDir, '.vite', 'netjes.json'), `${JSON.stringify(record, null, 2)}\n`);
462
+ },
463
+ };
464
+
465
+ /**
466
+ * Adds each page package's stylesheet and source folders to the app's Tailwind CSS build. Before @tailwindcss/vite,
467
+ * whose plugins run before the others too (`enforce: 'pre'`), so the order in the list decides.
468
+ * @type {import('vite').Plugin}
469
+ */
470
+ const packageStyles = {
471
+ name: 'netjes:package-styles',
472
+ enforce: 'pre',
473
+ transform(code, id) {
474
+ if (!isTailwindStylesheet(id)) return undefined;
475
+ const css = withPackageStyles(code, id.split('?')[0] ?? id, packages);
476
+ return css === code ? undefined : { code: css, map: null };
287
477
  },
288
478
  };
289
479
 
290
- return [react(options.react), plugin, options.tailwind === false ? null : tailwind()];
480
+ return [react(options.react), plugin, packageNames.length > 0 ? packageStyles : null, options.tailwind === false ? null : tailwind()];
291
481
  }