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.
@@ -0,0 +1,345 @@
1
+ // @ts-check
2
+ // Page packages: npm packages that bring pages for the app to mount, marked by a `netjes` field in their package.json.
3
+ // The Vite plugin, the development sidecar (through the plugin's api) and the CLI read them from here.
4
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
5
+ import { readdir } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { satisfies } from '../cli/info.mjs';
8
+ import { findPackage } from '../cli/packages.mjs';
9
+
10
+ /** The names of the convention files, which the route table routes; every other file in a pages folder is colocated code. */
11
+ export const CONVENTION_FILES = ['page', 'layout', 'template', 'loading', 'error', 'not-found', 'forbidden', 'unauthorized', 'global-error', 'default'];
12
+ /** The extensions of convention files. */
13
+ export const CONVENTION_EXTENSIONS = ['.tsx', '.jsx'];
14
+
15
+ /**
16
+ * A page package as the app installed it.
17
+ * @typedef {object} PagePackage
18
+ * @property {string} name The npm name, which starts every file id of the package.
19
+ * @property {string} version
20
+ * @property {string} netjes Its `peerDependencies.netjes`: the Netjes versions it supports.
21
+ * @property {string} root The real path of its folder, with `/` separators: a link (a workspace, `npm link`) resolved.
22
+ * @property {string} pages The folder of its pages, relative to `root`.
23
+ * @property {string[]} sources The folders Tailwind scans for its classes, relative to `root`.
24
+ * @property {string | null} css A stylesheet the app's Tailwind build imports, relative to `root`.
25
+ * @property {string | null} nuget The NuGet package of its C# half.
26
+ * @property {string[]} dependencies The names in its `dependencies`.
27
+ * @property {boolean} linked Whether `root` lies outside every node_modules folder: a workspace or `npm link` links it,
28
+ * and its files may change while the dev server runs.
29
+ */
30
+
31
+ /** @param {string} file */
32
+ const slashes = (file) => file.replaceAll('\\', '/');
33
+
34
+ /** npm's rule for the name of a new package: lowercase, URL-safe, and at most one scope. */
35
+ const PACKAGE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
36
+
37
+ /**
38
+ * Whether a name is one npm gives a new package, and so no path: no `.` or `..`, and no `/` but the scope's.
39
+ * @param {unknown} name
40
+ * @returns {name is string}
41
+ */
42
+ export const isPackageName = (name) => typeof name === 'string' && PACKAGE_NAME.test(name);
43
+
44
+ /**
45
+ * Whether a path from a package's `netjes` field is relative and stays inside the package: no `..` or `.`, no leading
46
+ * `/`, no `\`, no drive letter, no empty folder. The file ids of the package are built from it, so it has one spelling.
47
+ * @param {unknown} value
48
+ * @returns {value is string}
49
+ */
50
+ function isInsidePath(value) {
51
+ return (
52
+ typeof value === 'string' &&
53
+ !value.includes('\\') &&
54
+ !value.includes(':') &&
55
+ value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
56
+ );
57
+ }
58
+
59
+ /** @param {string} file */
60
+ function isDirectory(file) {
61
+ try {
62
+ return statSync(file).isDirectory();
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /** @param {string} file */
69
+ function isFile(file) {
70
+ try {
71
+ return statSync(file).isFile();
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Reads and checks the page packages the app lists, in the order it lists them. Each error is one line that names the
79
+ * package and what to do.
80
+ * @param {string} root The app's folder, from which the packages resolve.
81
+ * @param {readonly string[]} names
82
+ * @param {string} appDir The app's folder of pages, whose file ids a package's must not look like.
83
+ * @returns {PagePackage[]}
84
+ */
85
+ export function resolvePagePackages(root, names, appDir) {
86
+ /** @type {PagePackage[]} */
87
+ const packages = [];
88
+ const seen = new Set();
89
+ for (const name of names) {
90
+ if (!isPackageName(name)) {
91
+ throw new Error(`netjes: ${JSON.stringify(name)} in netjes({ packages }) is not the name of an npm package`);
92
+ }
93
+ if (seen.has(name)) throw new Error(`netjes: '${name}' is listed twice in netjes({ packages })`);
94
+ seen.add(name);
95
+ // A package's file ids start with its name, and the app's with its folder of pages (`app`, `src/app`).
96
+ if (name === appDir || appDir.startsWith(`${name}/`)) {
97
+ throw new Error(`netjes: '${name}' in netjes({ packages }) is named like the app's folder of pages, ${appDir}, so its pages would be taken for the app's`);
98
+ }
99
+ packages.push(readPagePackage(root, name));
100
+ }
101
+ return packages;
102
+ }
103
+
104
+ /**
105
+ * @param {string} root
106
+ * @param {string} name
107
+ * @returns {PagePackage}
108
+ */
109
+ function readPagePackage(root, name) {
110
+ const manifestPath = findPackage(root, name);
111
+ if (!manifestPath) throw new Error(`netjes: '${name}' in netjes({ packages }) is not installed: npm install ${name}`);
112
+ const manifest = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(manifestPath, 'utf8')));
113
+ const field = manifest.netjes;
114
+ if (typeof field !== 'object' || field === null || Array.isArray(field)) {
115
+ throw new Error(`netjes: '${name}' in netjes({ packages }) is not a page package: its package.json has no "netjes" field`);
116
+ }
117
+ const options = /** @type {Record<string, unknown>} */ (field);
118
+ const peers = manifest.peerDependencies;
119
+ const range = typeof peers === 'object' && peers !== null ? /** @type {Record<string, unknown>} */ (peers).netjes : undefined;
120
+ if (typeof range !== 'string' || range.trim() === '') {
121
+ throw new Error(`netjes: '${name}' has no peerDependencies.netjes in its package.json: a page package names the Netjes versions it supports there`);
122
+ }
123
+ const dir = slashes(realpathSync.native(path.dirname(manifestPath)));
124
+
125
+ /**
126
+ * @param {string} key
127
+ * @param {unknown} value
128
+ */
129
+ const inside = (key, value) => {
130
+ if (!isInsidePath(value)) {
131
+ throw new Error(
132
+ `netjes: '${name}' has "${key}": ${JSON.stringify(value)} in the "netjes" field of its package.json: ` +
133
+ 'a path there is relative and stays inside the package (no "..", no leading "/", no "\\")',
134
+ );
135
+ }
136
+ return value;
137
+ };
138
+
139
+ const pages = inside('pages', options.pages ?? 'app');
140
+ /** @type {string[]} */
141
+ let sources;
142
+ if (options.sources === undefined) {
143
+ sources = isDirectory(path.join(dir, 'src')) && pages !== 'src' ? [pages, 'src'] : [pages];
144
+ } else {
145
+ if (!Array.isArray(options.sources)) {
146
+ throw new Error(`netjes: '${name}' has "sources": ${JSON.stringify(options.sources)} in the "netjes" field of its package.json: it is a list of folders`);
147
+ }
148
+ sources = options.sources.map((source) => inside('sources', source));
149
+ for (const source of sources) {
150
+ if (!isDirectory(path.join(dir, source))) {
151
+ throw new Error(`netjes: '${name}' lists "${source}" in "sources" in the "netjes" field of its package.json, which is no folder in the package`);
152
+ }
153
+ }
154
+ }
155
+ const css = options.css === undefined ? null : inside('css', options.css);
156
+ if (css !== null && !isFile(path.join(dir, css))) {
157
+ throw new Error(`netjes: '${name}' has "css": "${css}" in the "netjes" field of its package.json, which is no file in the package`);
158
+ }
159
+ // A package published without it (its `files` leave it out) would otherwise bring no pages, without a word.
160
+ if (!isDirectory(path.join(dir, pages))) {
161
+ throw new Error(
162
+ options.pages === undefined
163
+ ? `netjes: '${name}' has no app folder, where a page package's pages are unless "pages" in the "netjes" field of its package.json names another`
164
+ : `netjes: '${name}' has "pages": "${pages}" in the "netjes" field of its package.json, which is no folder in the package`,
165
+ );
166
+ }
167
+ const nuget = typeof options.nuget === 'string' ? options.nuget : null;
168
+ const dependencies = typeof manifest.dependencies === 'object' && manifest.dependencies !== null ? Object.keys(manifest.dependencies) : [];
169
+ return {
170
+ name,
171
+ version: typeof manifest.version === 'string' ? manifest.version : '',
172
+ netjes: range,
173
+ root: dir,
174
+ pages,
175
+ sources,
176
+ css,
177
+ nuget,
178
+ dependencies,
179
+ linked: !dir.split('/').includes('node_modules'),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Why a package cannot run on this Netjes: its `peerDependencies.netjes`, read strictly as npm reads a range, leaves out
185
+ * `netjes`, the installed version. Null when it holds it. The .NET app says the same about the Netjes it runs.
186
+ * @param {PagePackage} pkg
187
+ * @param {string} netjes
188
+ * @returns {string | null}
189
+ */
190
+ export function unsupportedNetjes(pkg, netjes) {
191
+ return satisfies(netjes, pkg.netjes, { strict: true }) ? null : `${pkg.name} ${pkg.version} supports Netjes ${pkg.netjes}; this app runs Netjes ${netjes}`;
192
+ }
193
+
194
+ /**
195
+ * The files a package's `exports` names, in every condition.
196
+ * @param {unknown} exports
197
+ * @returns {string[]}
198
+ */
199
+ const exportTargets = (exports) =>
200
+ typeof exports === 'string' ? [exports] : typeof exports === 'object' && exports !== null ? Object.values(exports).flatMap(exportTargets) : [];
201
+
202
+ /**
203
+ * Whether a package has JavaScript for Vite to pre-bundle: a script its `exports` names, or else its `main`
204
+ * (`index.js` by default), as Node resolves it. A package of types or of stylesheets has none.
205
+ * @param {string} dir
206
+ * @param {Record<string, unknown>} manifest
207
+ */
208
+ function hasScript(dir, manifest) {
209
+ if (manifest.exports !== undefined) return exportTargets(manifest.exports).some((target) => /\.c?js$/.test(target));
210
+ const main = typeof manifest.main === 'string' && manifest.main !== '' ? manifest.main : 'index.js';
211
+ if (!['', '.js', '.cjs'].includes(path.extname(main))) return false;
212
+ return [main, `${main}.js`, path.join(main, 'index.js')].some((file) => isFile(path.join(dir, file)));
213
+ }
214
+
215
+ /**
216
+ * The package's dependencies that are CommonJS, as Vite has to pre-bundle them for the browser: JavaScript with neither
217
+ * `"type": "module"`, a `module` entry nor an `import` condition in its `exports`. A dependency that is not installed
218
+ * for the package, one with no JavaScript, and netjes, which Vite never pre-bundles, are left out.
219
+ * @param {PagePackage} pkg
220
+ * @returns {string[]}
221
+ */
222
+ export function commonJsDependencies(pkg) {
223
+ return pkg.dependencies.filter((dependency) => {
224
+ if (dependency === 'netjes') return false;
225
+ const manifestPath = findPackage(pkg.root, dependency);
226
+ if (!manifestPath) return false;
227
+ const manifest = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(manifestPath, 'utf8')));
228
+ if (manifest.type === 'module' || typeof manifest.module === 'string') return false;
229
+ if (/"import"\s*:|\.mjs"/.test(JSON.stringify(manifest.exports ?? null))) return false;
230
+ return hasScript(path.dirname(manifestPath), manifest);
231
+ });
232
+ }
233
+
234
+ /**
235
+ * A package's convention files, relative to its folder of pages, in ordinal order. Like the app's glob, this leaves out
236
+ * node_modules and hidden folders.
237
+ * @param {PagePackage} pkg
238
+ * @returns {Promise<string[]>}
239
+ */
240
+ export async function pagePackageFiles(pkg) {
241
+ const dir = path.join(pkg.root, pkg.pages);
242
+ /** @type {import('node:fs').Dirent[]} */
243
+ let entries;
244
+ try {
245
+ entries = await readdir(dir, { recursive: true, withFileTypes: true });
246
+ } catch {
247
+ return [];
248
+ }
249
+ const files = entries
250
+ .filter((entry) => entry.isFile() && isConventionFile(entry.name))
251
+ .map((entry) => slashes(path.relative(dir, path.join(entry.parentPath, entry.name))))
252
+ .filter((file) => file.split('/').slice(0, -1).every((folder) => folder !== 'node_modules' && !folder.startsWith('.')));
253
+ // UTF-16 code unit order, as the route table's ordinal order.
254
+ return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
255
+ }
256
+
257
+ /**
258
+ * Whether a file is a convention file, by its name.
259
+ * @param {string} name A file's name, with its extension.
260
+ */
261
+ export function isConventionFile(name) {
262
+ const { name: base, ext } = path.parse(name);
263
+ return CONVENTION_FILES.includes(base) && CONVENTION_EXTENSIONS.includes(ext);
264
+ }
265
+
266
+ /**
267
+ * The id of a package's file: its npm name, its folder of pages and the file's path in that folder, as the payload,
268
+ * the route table and errors name it, wherever the package is installed.
269
+ * @param {PagePackage} pkg
270
+ * @param {string} file Relative to the package's folder of pages.
271
+ */
272
+ export function fileId(pkg, file) {
273
+ return `${pkg.name}/${pkg.pages}/${file}`;
274
+ }
275
+
276
+ /**
277
+ * The real path of a package's file, with `/` separators.
278
+ * @param {PagePackage} pkg
279
+ * @param {string} file Relative to the package's folder of pages.
280
+ */
281
+ export function filePath(pkg, file) {
282
+ return `${pkg.root}/${pkg.pages}/${file}`;
283
+ }
284
+
285
+ /** The app's import of Tailwind CSS, in either quotes or as a url(), with Tailwind's import options (`source(none)`). */
286
+ const TAILWIND_IMPORT = /@import\s+(?:url\(\s*)?(["'])tailwindcss\1\s*\)?[^;]*;?/;
287
+ /** The file each `@import` of a stylesheet names. */
288
+ const IMPORTS = /@import\s+(?:url\(\s*)?(["'])(.*?)\1/g;
289
+
290
+ /**
291
+ * A path from a stylesheet's folder, as Tailwind CSS resolves `@import` and `@source`: relative, with `/` separators,
292
+ * starting with `./` or `../`, so that no drive letter is in it on Windows, and nothing reads it as a package name.
293
+ * @param {string} dir
294
+ * @param {string} target
295
+ */
296
+ function fromStylesheet(dir, target) {
297
+ const relative = slashes(path.relative(dir, target));
298
+ return relative.startsWith('../') || path.isAbsolute(relative) ? relative : `./${relative}`;
299
+ }
300
+
301
+ /**
302
+ * Whether a stylesheet imports a package's own stylesheet already: by the package's name and the path in its `css`,
303
+ * or by a relative path to the file.
304
+ * @param {string} css
305
+ * @param {string} dir The stylesheet's folder.
306
+ * @param {PagePackage} pkg
307
+ */
308
+ function importsPackageCss(css, dir, pkg) {
309
+ if (pkg.css === null) return false;
310
+ const own = `${pkg.root}/${pkg.css}`;
311
+ for (const [, , file = ''] of css.matchAll(IMPORTS)) {
312
+ if (file === `${pkg.name}/${pkg.css}`) return true;
313
+ if (!file.startsWith('./') && !file.startsWith('../')) continue;
314
+ try {
315
+ if (slashes(realpathSync.native(path.resolve(dir, file))) === own) return true;
316
+ } catch {
317
+ // A file that is not there is not the package's.
318
+ }
319
+ }
320
+ return false;
321
+ }
322
+
323
+ /**
324
+ * A stylesheet of the app with the packages added to its import of Tailwind CSS: an `@import` of each package's own
325
+ * stylesheet, unless it imports that already, and an `@source` for each of its source folders, which Tailwind would not
326
+ * scan otherwise (its automatic detection leaves out node_modules and every folder outside the app). They go on the
327
+ * line of the import, so the lines below keep their numbers. A stylesheet without that import, or one in a package, is
328
+ * returned as it is.
329
+ * @param {string} css
330
+ * @param {string} file The stylesheet's path, with `/` separators.
331
+ * @param {readonly PagePackage[]} packages
332
+ */
333
+ export function withPackageStyles(css, file, packages) {
334
+ if (packages.some((pkg) => file.startsWith(`${pkg.root}/`))) return css;
335
+ const match = TAILWIND_IMPORT.exec(css);
336
+ if (!match) return css;
337
+ const dir = path.dirname(file);
338
+ const added = packages.flatMap((pkg) => [
339
+ ...(pkg.css !== null && !importsPackageCss(css, dir, pkg) ? [`@import ${JSON.stringify(fromStylesheet(dir, `${pkg.root}/${pkg.css}`))};`] : []),
340
+ ...pkg.sources.map((source) => `@source ${JSON.stringify(fromStylesheet(dir, `${pkg.root}/${source}`))};`),
341
+ ]);
342
+ const end = match.index + match[0].length;
343
+ const statement = match[0].endsWith(';') ? match[0] : `${match[0]};`;
344
+ return `${css.slice(0, match.index)}${statement} ${added.join(' ')}${css.slice(end)}`;
345
+ }
package/vite/plugin.d.ts CHANGED
@@ -11,6 +11,13 @@ export type NetjesPluginOptions = {
11
11
  * Built asset URLs get it; links, forms, redirects and router calls get it from the server at runtime.
12
12
  */
13
13
  basePath?: string;
14
+ /**
15
+ * Page packages, by npm name: installed packages whose `package.json` has a `netjes` field, and which the .NET app
16
+ * mounts at a prefix. Their convention files join the page module map and both builds, their sources and stylesheet
17
+ * join the app's Tailwind CSS build, and the client build records them in `dist/client/.vite/netjes.json`, where the
18
+ * .NET app finds them in production. Needs Tailwind CSS on.
19
+ */
20
+ packages?: readonly string[];
14
21
  /** Options for @vitejs/plugin-react, which netjes() includes. */
15
22
  react?: ReactOptions;
16
23
  /** Tailwind CSS through @tailwindcss/vite. Default true; `@import "tailwindcss"` in a CSS file a layout imports. */