netjes 0.0.3 → 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.
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.
@@ -22,6 +39,13 @@ const PAGES_ID = 'virtual:netjes/pages';
22
39
  const RESOLVED_PAGES_ID = `\0${PAGES_ID}`;
23
40
  const QUERY_ID = 'virtual:netjes/query';
24
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');
25
49
  /** The URL the .NET dev asset resolver points browsers at. */
26
50
  const DEV_CLIENT_ENTRY_URL = '/@netjes/client-entry';
27
51
 
@@ -40,10 +64,24 @@ const BACKEND_DIRS = ['bin', 'obj', 'dist/server'];
40
64
  * @param {string} workspaceRoot
41
65
  */
42
66
  function backendDeny(workspaceRoot) {
43
- const root = normalizePath(workspaceRoot).replace(/[()[\]{}!+@*?\\]/g, '\\$&');
44
- return [...BACKEND_FILES, ...BACKEND_DIRS.map((dir) => `${root}/**/${dir}/**`)];
67
+ return [...BACKEND_FILES, ...backendDirs(workspaceRoot)];
45
68
  }
46
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
+
47
85
  /** Loads @tailwindcss/vite only when Tailwind is on, so apps that turn it off need not install it. */
48
86
  async function tailwind() {
49
87
  try {
@@ -161,7 +199,11 @@ async function findRouteHandler(root, appDir) {
161
199
  export default function netjes(options = {}) {
162
200
  const appDir = (options.appDir ?? 'app').replace(/^\/+|\/+$/g, '');
163
201
  // Only the convention files are routes; everything else in app/ is colocated code.
164
- 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
+ }
165
207
  const basePath = (options.basePath ?? '').replace(/\/+$/, '');
166
208
  const query = options.query === false ? null : options.query === true || options.query === undefined ? {} : options.query;
167
209
  // Fail when the config loads, not in the browser.
@@ -174,6 +216,47 @@ export default function netjes(options = {}) {
174
216
  let outDir = '';
175
217
  /** @type {import('vite').ViteDevServer | undefined} */
176
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
+ }
177
260
 
178
261
  /** @type {import('vite').Plugin} */
179
262
  const plugin = {
@@ -185,6 +268,13 @@ export default function netjes(options = {}) {
185
268
  const userTest = /** @type {{ test?: Record<string, unknown> }} */ (/** @type {unknown} */ (config)).test;
186
269
  const root = path.resolve(config.root ?? process.cwd());
187
270
  const workspaceRoot = searchForWorkspaceRoot(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);
188
278
  const test = env.command === 'serve' && options.test !== false ? { test: testDefaults(userTest, root, query) } : {};
189
279
  // Built asset URLs live under the base path; in development the browser loads modules from the Vite server's root.
190
280
  const base = env.command === 'build' && basePath ? { base: `${basePath}/` } : {};
@@ -200,21 +290,30 @@ export default function netjes(options = {}) {
200
290
  ...(query ? ['@tanstack/react-query'] : []),
201
291
  // The test setup's cleanup must unmount what the app's tests rendered.
202
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'] : [])] : []),
203
296
  ],
204
297
  },
205
298
  // netjes serves its own modules (client entry, runtime) and, for their source maps, its sources; a linked
206
- // package lives outside the app. The workspace root keeps Vite's default, but only when the app did not narrow
207
- // fs.allow itself: Vite concatenates arrays. The dev root is the .NET content root, so deny the backend's files.
208
- // 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.
209
304
  server: {
210
305
  fs: {
211
- allow: [...(config.server?.fs?.allow ? [] : [workspaceRoot]), PACKAGE_ROOT],
212
- 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))],
213
308
  },
214
309
  },
215
310
  optimizeDeps: {
216
- // There is no index.html to scan; start from the pages so dependencies are pre-bundled up front.
217
- 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
+ ],
218
317
  include: [
219
318
  'react',
220
319
  'react-dom',
@@ -222,14 +321,19 @@ export default function netjes(options = {}) {
222
321
  ...(query ? ['@tanstack/react-query'] : []),
223
322
  // Imported from inside netjes (netjes/vitals), which the dependency scan skips: found late, it would reload the page.
224
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}`)),
225
326
  ],
226
327
  // netjes imports the virtual modules below, which pre-bundling cannot resolve, and the plugin loads its query
227
328
  // shell by path: a pre-bundled copy of netjes would hold a second router, whose contexts the app's pages miss.
228
- 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],
229
332
  },
230
333
  // netjes imports the plugin's virtual modules and must share its module instances with the app's pages, so Vite
231
- // processes it in development as well; production bundles everything for the sidecar.
232
- 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] },
233
337
  build: isSsrBuild
234
338
  ? {
235
339
  outDir: 'dist/server',
@@ -254,27 +358,52 @@ export default function netjes(options = {}) {
254
358
  // takes only the convention files, so it would leave one out without a word: it stops on it too, saying the same.
255
359
  async buildStart() {
256
360
  if (!isBuild) return;
257
- 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
+ }
258
368
  if (handler) {
259
369
  this.error(`'${handler}': route handlers are C# in Netjes. Map a minimal API endpoint instead (app.MapGet, app.MapPost, ...).`);
260
370
  }
261
371
  },
262
372
  configureServer(server) {
263
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);
264
386
  },
265
387
  // For the development sidecar, which finds this plugin in its dev server's config.
266
388
  api: {
267
389
  /**
268
- * Has the next render, and the next page the browser loads, build the page module map again from the files on
269
- * 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}
270
395
  */
271
- rescanPages() {
272
- const timestamp = Date.now();
273
- for (const { moduleGraph } of Object.values(devServer?.environments ?? {})) {
274
- const pages = moduleGraph.getModuleById(RESOLVED_PAGES_ID);
275
- if (pages) moduleGraph.invalidateModule(pages, new Set(), timestamp, true);
276
- }
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}`;
277
402
  },
403
+ unlistedPackage,
404
+ /** The page packages the app lists. */
405
+ packages: () => packages,
406
+ rescanPages,
278
407
  },
279
408
  resolveId(id) {
280
409
  if (id === PAGES_ID) return RESOLVED_PAGES_ID;
@@ -282,22 +411,71 @@ export default function netjes(options = {}) {
282
411
  if (id === DEV_CLIENT_ENTRY_URL) return CLIENT_ENTRY;
283
412
  return undefined;
284
413
  },
285
- load(id) {
414
+ async load(id) {
286
415
  if (id === RESOLVED_QUERY_ID) {
287
416
  if (!query) return 'export const QueryShell = undefined;\n';
288
417
  return `import { createQueryShell } from ${JSON.stringify(QUERY_MODULE)};\nexport const QueryShell = createQueryShell(${queryLiteral});\n`;
289
418
  }
290
419
  if (id !== RESOLVED_PAGES_ID) return undefined;
291
- const glob = JSON.stringify([appFiles]);
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(', ')} }`;
292
431
  // The folder the keys start with: renderDocument (netjes/testing) builds routes and segment ids from it.
293
- return `export const pages = import.meta.glob(${glob});\nexport const appDir = ${JSON.stringify(appDir)};\n`;
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
+ );
294
436
  },
295
437
  async writeBundle() {
296
- if (!isSsrBuild) return;
297
- await mkdir(outDir, { recursive: true });
298
- 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 };
299
477
  },
300
478
  };
301
479
 
302
- 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()];
303
481
  }