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/README.md +10 -8
- package/cli/info.mjs +268 -31
- package/cli/routes.mjs +35 -8
- package/dist/href.d.ts +7 -0
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +16 -5
- package/dist/href.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mounted.d.ts +55 -0
- package/dist/mounted.d.ts.map +1 -0
- package/dist/mounted.js +110 -0
- package/dist/mounted.js.map +1 -0
- package/dist/payload.d.ts +8 -0
- package/dist/payload.d.ts.map +1 -1
- package/dist/payload.js +28 -0
- package/dist/payload.js.map +1 -1
- package/dist/render-document.d.ts +18 -4
- package/dist/render-document.d.ts.map +1 -1
- package/dist/render-document.js +79 -16
- package/dist/render-document.js.map +1 -1
- package/dist/testing.d.ts +6 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -0
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/sidecar/server.mjs +37 -10
- package/skills/netjes/SKILL.md +4 -3
- package/skills/netjes/references/data-and-actions.md +6 -2
- package/skills/netjes/references/routing.md +14 -3
- package/src/href.ts +16 -5
- package/src/index.ts +18 -1
- package/src/mounted.tsx +160 -0
- package/src/payload.ts +32 -0
- package/src/render-document.tsx +117 -20
- package/src/testing.tsx +7 -0
- package/src/virtual.d.ts +6 -1
- package/template/_AGENTS.md +4 -3
- package/vite/packages.mjs +345 -0
- package/vite/plugin.d.ts +7 -0
- package/vite/plugin.mjs +208 -30
package/src/payload.ts
CHANGED
|
@@ -121,6 +121,12 @@ export type PagePayload = {
|
|
|
121
121
|
basePath?: string;
|
|
122
122
|
/** The request culture, with `AddNetjesLocalization`. */
|
|
123
123
|
locale?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Each mounted page package's URL for this request, by npm name, without the base path: `/help`, `/acme/help`, or `/`
|
|
126
|
+
* for a package mounted at the root. Null when the request has no value for one of the prefix's parameters. Left out
|
|
127
|
+
* when the app mounts no package.
|
|
128
|
+
*/
|
|
129
|
+
mounts?: Record<string, string | null>;
|
|
124
130
|
/**
|
|
125
131
|
* Client only, never sent: how far the browser clock is ahead of the server's, measured when the payload arrived.
|
|
126
132
|
* Server timestamps plus this offset are browser timestamps.
|
|
@@ -333,6 +339,7 @@ function pagePayload(json: Json): PagePayload {
|
|
|
333
339
|
if (json.renderedAt !== undefined && json.renderedAt !== null) payload.renderedAt = number(json.renderedAt, 'renderedAt');
|
|
334
340
|
if (json.basePath !== undefined && json.basePath !== null) payload.basePath = string(json.basePath, 'basePath');
|
|
335
341
|
if (json.locale !== undefined && json.locale !== null) payload.locale = string(json.locale, 'locale');
|
|
342
|
+
if (json.mounts !== undefined && json.mounts !== null) payload.mounts = mounts(json.mounts);
|
|
336
343
|
if (json.draftMode !== undefined && json.draftMode !== null) {
|
|
337
344
|
if (typeof json.draftMode !== 'boolean') fail('draftMode must be a boolean');
|
|
338
345
|
payload.draftMode = json.draftMode;
|
|
@@ -340,6 +347,31 @@ function pagePayload(json: Json): PagePayload {
|
|
|
340
347
|
return payload;
|
|
341
348
|
}
|
|
342
349
|
|
|
350
|
+
/** A segment a browser resolves away, also when encoded: `.`, `..`, `%2e`, `.%2E` and the like. */
|
|
351
|
+
export function isDotSegment(segment: string): boolean {
|
|
352
|
+
return /^(?:\.|%2e){1,2}$/i.test(segment);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A mount's URL as the server writes it: `/`, or `/` and segments, none empty or a dot segment, with no query, fragment,
|
|
357
|
+
* backslash, whitespace or control character. The mounted components put it in front of their hrefs.
|
|
358
|
+
*/
|
|
359
|
+
function mountUrl(value: unknown, what: string): string | null {
|
|
360
|
+
if (value === null) return null;
|
|
361
|
+
const url = string(value, what);
|
|
362
|
+
if (url === '/') return url;
|
|
363
|
+
const [first, ...segments] = url.split('/');
|
|
364
|
+
const unsafe = (segment: string) => segment === '' || isDotSegment(segment) || /[?#\\\s\u0000-\u001F\u007F]/.test(segment);
|
|
365
|
+
if (first !== '' || segments.length === 0 || segments.some(unsafe)) fail(`${what} must be a path of the app, such as /help`);
|
|
366
|
+
return url;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function mounts(value: unknown): Record<string, string | null> {
|
|
370
|
+
const result: Record<string, string | null> = {};
|
|
371
|
+
for (const [name, url] of Object.entries(object(value, 'mounts'))) result[name] = mountUrl(url, `mounts.${name}`);
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
|
|
343
375
|
export function parseNavigationPayload(value: unknown): NavigationPayload {
|
|
344
376
|
const json = object(value, 'payload');
|
|
345
377
|
switch (json.kind) {
|
package/src/render-document.tsx
CHANGED
|
@@ -32,7 +32,17 @@ export type RenderDocumentOptions = {
|
|
|
32
32
|
lang?: string;
|
|
33
33
|
/** What `usePublicConfig()` reads, as the `Netjes:PublicConfig` section. */
|
|
34
34
|
publicConfig?: Record<string, unknown>;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* The page packages the app mounts, by npm name, each at its prefix as `MapPagePackage` takes it:
|
|
37
|
+
* `{ '@acme/help-center': '/(marketing)/help' }`. Their files in the pages map go into the app's folders under the
|
|
38
|
+
* prefix, so an app page in a package's folder renders inside the package's layouts, and the payload's `mounts` gives
|
|
39
|
+
* each one's URL for the page's `params`. A package that `netjes({ packages })` does not list has its pages in `app`.
|
|
40
|
+
*/
|
|
41
|
+
mounts?: Record<string, string>;
|
|
42
|
+
/**
|
|
43
|
+
* The page files, keyed `/app/...` as `import.meta.glob` keys them, and a page package's `/<npm name>/<its pages
|
|
44
|
+
* folder>/...`. Default: the app's and the listed packages', from the `netjes()` plugin.
|
|
45
|
+
*/
|
|
36
46
|
modules?: Record<string, () => Promise<unknown>>;
|
|
37
47
|
/** Stops the render, which then rejects with the signal's reason: for a page that waits on a Promise that never settles. */
|
|
38
48
|
signal?: AbortSignal;
|
|
@@ -65,6 +75,65 @@ function list(items: readonly string[]): string {
|
|
|
65
75
|
return items.length < 2 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items.at(-1) ?? ''}`;
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
/** A page package mounted at a prefix: the prefix's folders below the app directory (`''` for `/`), and its pages folder. */
|
|
79
|
+
type Mount = { name: string; folder: string; pages: string };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A file of the pages map the server routes: its id, its path in the app directory (a package's under its mount), and
|
|
83
|
+
* its source, `app` or the package's name.
|
|
84
|
+
*/
|
|
85
|
+
type RoutedFile = { id: string; path: string; source: string };
|
|
86
|
+
|
|
87
|
+
const PREFIX_SEGMENT = /^(?:[\p{L}\p{N}._~-]+|\[[A-Za-z_][A-Za-z0-9_]*\]|\([\p{L}\p{N}_-]+\))$/u;
|
|
88
|
+
|
|
89
|
+
/** The mounts of `options.mounts`, with their pages folders from the plugin's list; throws for a prefix MapPagePackage refuses. */
|
|
90
|
+
function mountsOf(mounts: Readonly<Record<string, string>> | undefined, packages: readonly { name: string; pages: string }[]): Mount[] {
|
|
91
|
+
return Object.entries(mounts ?? {}).map(([name, prefix]) => {
|
|
92
|
+
const folders = prefix === '/' ? [] : prefix.split('/').slice(1);
|
|
93
|
+
const refused = (folder: string) => folder === '.' || folder === '..' || isPrivate(folder) || !PREFIX_SEGMENT.test(folder);
|
|
94
|
+
// A parameter used twice, in any case, as MapPagePackage refuses it.
|
|
95
|
+
const parameters = folders.filter((folder) => folder.startsWith('[')).map((folder) => folder.toLowerCase());
|
|
96
|
+
if (!prefix.startsWith('/') || folders.some(refused) || new Set(parameters).size < parameters.length) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`netjes: renderDocument cannot mount '${name}' at '${prefix}': write the prefix as MapPagePackage takes it, ` +
|
|
99
|
+
"such as '/help', '/(marketing)/help' or '/[tenant]/help'.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return { name, folder: folders.join('/'), pages: packages.find((p) => p.name === name)?.pages ?? 'app' };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The files the server routes: the app's where they are, and a mounted package's under its prefix's folders. */
|
|
107
|
+
function routedFiles(files: readonly string[], appDir: string, mounts: readonly Mount[]): RoutedFile[] {
|
|
108
|
+
return files.flatMap((id): RoutedFile[] => {
|
|
109
|
+
if (id.startsWith(`${appDir}/`)) return [{ id, path: id, source: 'app' }];
|
|
110
|
+
const mount = mounts.find((m) => id.startsWith(`${m.name}/${m.pages}/`));
|
|
111
|
+
if (!mount) return [];
|
|
112
|
+
const below = id.slice(mount.name.length + mount.pages.length + 2);
|
|
113
|
+
return [{ id, path: [appDir, mount.folder, below].filter(Boolean).join('/'), source: mount.name }];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Each mount's URL for these route values, as the server sends it: the literal segments as written and each value
|
|
119
|
+
* encoded as one segment, or null when a value is missing, a list, empty, `.` or `..`.
|
|
120
|
+
*/
|
|
121
|
+
function mountUrls(mounts: readonly Mount[], params: Params): Record<string, string | null> {
|
|
122
|
+
// As the server encodes a value (Uri.EscapeDataString), which encodeURIComponent leaves these five unencoded.
|
|
123
|
+
const encode = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
124
|
+
return Object.fromEntries(
|
|
125
|
+
mounts.map((mount) => {
|
|
126
|
+
const segments: string[] = [];
|
|
127
|
+
for (const folder of mount.folder.split('/').filter((f) => f && !isGroup(f))) {
|
|
128
|
+
const value = folder.startsWith('[') ? params[folder.slice(1, -1)] : folder;
|
|
129
|
+
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') return [mount.name, null];
|
|
130
|
+
segments.push(folder.startsWith('[') ? encode(value) : value);
|
|
131
|
+
}
|
|
132
|
+
return [mount.name, `/${segments.join('/')}`];
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
68
137
|
/** A file in the app directory: the folders below the directory and the file's name without its extension; null for any other file. */
|
|
69
138
|
function parse(file: string, appDir: string): { folders: string[]; name: string } | null {
|
|
70
139
|
if (!file.startsWith(`${appDir}/`) || !PAGE_EXTENSION.test(file)) return null;
|
|
@@ -75,17 +144,17 @@ function parse(file: string, appDir: string): { folders: string[]; name: string
|
|
|
75
144
|
|
|
76
145
|
type AppFiles = {
|
|
77
146
|
/** Each folder's convention files by name, by its path below the app directory (`''` for the directory itself). */
|
|
78
|
-
folders: Map<string, Map<string,
|
|
147
|
+
folders: Map<string, Map<string, RoutedFile>>;
|
|
79
148
|
/** The folders with parallel route slots, each with the first of its slots. */
|
|
80
149
|
slots: Map<string, string>;
|
|
81
150
|
};
|
|
82
151
|
|
|
83
152
|
/** The convention files the server routes: none in private folders, and the slots that pages and defaults make. */
|
|
84
|
-
function appFiles(files: readonly
|
|
85
|
-
const folders = new Map<string, Map<string,
|
|
153
|
+
function appFiles(files: readonly RoutedFile[], appDir: string): AppFiles {
|
|
154
|
+
const folders = new Map<string, Map<string, RoutedFile>>();
|
|
86
155
|
const slots = new Map<string, string>();
|
|
87
|
-
for (const file of [...files].sort(ordinal)) {
|
|
88
|
-
const parsed = parse(file, appDir);
|
|
156
|
+
for (const file of [...files].sort((a, b) => ordinal(a.id, b.id))) {
|
|
157
|
+
const parsed = parse(file.path, appDir);
|
|
89
158
|
if (!parsed || parsed.folders.some(isPrivate)) continue;
|
|
90
159
|
const slot = parsed.folders.findIndex(isSlot);
|
|
91
160
|
if (slot >= 0) {
|
|
@@ -98,7 +167,12 @@ function appFiles(files: readonly string[], appDir: string): AppFiles {
|
|
|
98
167
|
const folder = parsed.folders.join('/');
|
|
99
168
|
let conventions = folders.get(folder);
|
|
100
169
|
if (!conventions) folders.set(folder, (conventions = new Map()));
|
|
101
|
-
|
|
170
|
+
const other = conventions.get(parsed.name);
|
|
171
|
+
// As the server, which refuses two sources' segment files in one folder.
|
|
172
|
+
if (other && other.source !== file.source && parsed.name !== 'page') {
|
|
173
|
+
throw new Error(`netjes: renderDocument has two ${parsed.name} files for ${[appDir, folder].filter(Boolean).join('/')}: ${other.id} and ${file.id}.`);
|
|
174
|
+
}
|
|
175
|
+
if (!other) conventions.set(parsed.name, file);
|
|
102
176
|
}
|
|
103
177
|
return { folders, slots };
|
|
104
178
|
}
|
|
@@ -141,7 +215,7 @@ function segmentsFor(page: string, app: AppFiles, folders: readonly string[], ap
|
|
|
141
215
|
if (!conventions || !SEGMENT_FILES.some((name) => conventions.has(name))) continue;
|
|
142
216
|
const segment: SegmentRef = { id: folder ? `${appDir}/${folder}` : appDir };
|
|
143
217
|
for (const name of ['layout', 'template', 'loading', 'error'] as const) {
|
|
144
|
-
const file = conventions.get(name);
|
|
218
|
+
const file = conventions.get(name)?.id;
|
|
145
219
|
if (file) segment[name] = file;
|
|
146
220
|
}
|
|
147
221
|
if (segment.layout) {
|
|
@@ -154,44 +228,66 @@ function segmentsFor(page: string, app: AppFiles, folders: readonly string[], ap
|
|
|
154
228
|
}
|
|
155
229
|
|
|
156
230
|
/** Every file renderDocument could render from these: the pages and the boundary files, in ordinal order. */
|
|
157
|
-
function renderable(files: readonly
|
|
231
|
+
function renderable(files: readonly RoutedFile[], appDir: string): string[] {
|
|
158
232
|
return files
|
|
159
233
|
.filter((file) => {
|
|
160
|
-
const parsed = parse(file, appDir);
|
|
234
|
+
const parsed = parse(file.path, appDir);
|
|
161
235
|
if (!parsed || parsed.folders.some((folder) => isPrivate(folder) || isSlot(folder))) return false;
|
|
162
|
-
return parsed.name === 'page' || (BOUNDARIES.has(parsed.name) && (parsed.name !== 'global-error' || parsed
|
|
236
|
+
return parsed.name === 'page' || (BOUNDARIES.has(parsed.name) && (parsed.name !== 'global-error' || isRootGlobalError(file, parsed)));
|
|
163
237
|
})
|
|
238
|
+
.map(({ id }) => id)
|
|
164
239
|
.sort(ordinal);
|
|
165
240
|
}
|
|
166
241
|
|
|
242
|
+
/** The global error page is the app's own, in the app directory itself; the server refuses one in a package. */
|
|
243
|
+
const isRootGlobalError = (file: RoutedFile, parsed: { folders: string[] }) => file.source === 'app' && parsed.folders.length === 0;
|
|
244
|
+
|
|
167
245
|
/**
|
|
168
246
|
* The payload the server sends with the document of `page`, a file of the pages map named without its leading slash,
|
|
169
247
|
* for the values in `options`: the route, kind, status and segments by the server's rules, and a build with no
|
|
170
|
-
* stylesheets or scripts.
|
|
171
|
-
*
|
|
248
|
+
* stylesheets or scripts. The files of the page packages `options.mounts` names go under their prefixes, with their
|
|
249
|
+
* pages folders from `packages`. Throws for a file it cannot render: one not in `files`, a layout, a page in a private
|
|
250
|
+
* folder or under a parallel route slot, or a file of a package it does not mount.
|
|
172
251
|
*/
|
|
173
|
-
export function documentPayload(
|
|
252
|
+
export function documentPayload(
|
|
253
|
+
page: string,
|
|
254
|
+
options: DocumentOptions,
|
|
255
|
+
files: readonly string[],
|
|
256
|
+
appDir: string,
|
|
257
|
+
packages: readonly { name: string; pages: string }[] = [],
|
|
258
|
+
): InitialPayload {
|
|
174
259
|
const paths = files.map((file) => file.replace(/^\//, ''));
|
|
260
|
+
const mounts = mountsOf(options.mounts, packages);
|
|
261
|
+
const routed = routedFiles(paths, appDir, mounts);
|
|
175
262
|
if (!paths.includes(page)) {
|
|
176
263
|
const hint = page.startsWith('/') && paths.includes(page.slice(1))
|
|
177
264
|
? `; name the file without the leading slash: ${page.slice(1)}.`
|
|
178
|
-
: `. The files it can render are ${list(renderable(
|
|
265
|
+
: `. The files it can render are ${list(renderable(routed, appDir))}.`;
|
|
179
266
|
throw new Error(`netjes: renderDocument has no ${page} in the pages map${hint}`);
|
|
180
267
|
}
|
|
181
|
-
const
|
|
182
|
-
|
|
268
|
+
const file = routed.find((routedFile) => routedFile.id === page);
|
|
269
|
+
const parsed = file && parse(file.path, appDir);
|
|
270
|
+
if (!parsed) {
|
|
271
|
+
const unmounted = packages.find((p) => page.startsWith(`${p.name}/`))?.name;
|
|
272
|
+
throw new Error(
|
|
273
|
+
unmounted
|
|
274
|
+
? `netjes: renderDocument cannot render ${page}, a file of the page package '${unmounted}', which it does not mount: ` +
|
|
275
|
+
`pass its prefix as MapPagePackage takes it, as in mounts: { '${unmounted}': '/help' }.`
|
|
276
|
+
: `netjes: renderDocument cannot render ${page}, which is not in the app's directory, ${appDir}, or in a mounted page package's pages.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
183
279
|
if (parsed.folders.some(isPrivate)) throw new Error(`netjes: renderDocument cannot render ${page}: a private folder is not routed.`);
|
|
184
280
|
const slot = parsed.folders.findIndex(isSlot);
|
|
185
281
|
if (slot >= 0) throw slotError(page, 'is in', [appDir, ...parsed.folders.slice(0, slot + 1)].join('/'));
|
|
186
282
|
const boundary = BOUNDARIES.get(parsed.name);
|
|
187
|
-
if ((parsed.name !== 'page' && !boundary) || (parsed.name === 'global-error' && parsed
|
|
283
|
+
if ((parsed.name !== 'page' && !boundary) || (parsed.name === 'global-error' && !isRootGlobalError(file, parsed))) {
|
|
188
284
|
throw new Error(
|
|
189
285
|
`netjes: renderDocument renders a page or a not-found, error, forbidden, unauthorized or global-error file, not ${page}.`,
|
|
190
286
|
);
|
|
191
287
|
}
|
|
192
288
|
|
|
193
289
|
// The global error page renders without any layout, the others inside the segments down to their own folder.
|
|
194
|
-
const segments = parsed.name === 'global-error' ? [] : segmentsFor(page, appFiles(
|
|
290
|
+
const segments = parsed.name === 'global-error' ? [] : segmentsFor(page, appFiles(routed, appDir), parsed.folders, appDir, options);
|
|
195
291
|
for (const file of Object.keys(options.layouts ?? {})) {
|
|
196
292
|
if (!segments.some((segment) => segment.layout === file)) {
|
|
197
293
|
const layouts = segments.flatMap((segment) => (segment.layout ? [segment.layout] : []));
|
|
@@ -216,6 +312,7 @@ export function documentPayload(page: string, options: DocumentOptions, files: r
|
|
|
216
312
|
status: options.status ?? boundary?.status ?? 200,
|
|
217
313
|
url: options.url ?? '/',
|
|
218
314
|
version: 'test',
|
|
315
|
+
...(mounts.length > 0 ? { mounts: mountUrls(mounts, options.params ?? {}) } : {}),
|
|
219
316
|
document: {
|
|
220
317
|
lang: options.lang ?? 'en',
|
|
221
318
|
assets: { stylesheets: [], modulePreloads: [], scripts: [] },
|
|
@@ -294,7 +391,7 @@ export async function renderDocument(page: string, options: RenderDocumentOption
|
|
|
294
391
|
signal?.throwIfAborted();
|
|
295
392
|
const app = await import('virtual:netjes/pages');
|
|
296
393
|
const pages = modules ?? app.pages;
|
|
297
|
-
const payload = documentPayload(page, sent, Object.keys(pages), app.appDir);
|
|
394
|
+
const payload = documentPayload(page, sent, Object.keys(pages), app.appDir, app.packages);
|
|
298
395
|
// Loaded here, so that component tests, which import netjes/testing too, never load react-dom/server.
|
|
299
396
|
const { createServerRenderer } = await import('./server-render.tsx');
|
|
300
397
|
const stream = await createServerRenderer(pages).renderToStream(htmlSafeJson(payload), {
|
package/src/testing.tsx
CHANGED
|
@@ -26,6 +26,12 @@ export type TestAppOptions = {
|
|
|
26
26
|
params?: Params;
|
|
27
27
|
/** What the server would send as `SharedProps`. */
|
|
28
28
|
shared?: unknown;
|
|
29
|
+
/**
|
|
30
|
+
* Each mounted page package's URL for the page, by npm name, as the server sends it: `{ '@acme/help-center': '/help' }`,
|
|
31
|
+
* without the base path, or `null` for a prefix whose value the page has none for. The links, forms and router hooks
|
|
32
|
+
* of the package's generated file go below it.
|
|
33
|
+
*/
|
|
34
|
+
mounts?: Record<string, string | null>;
|
|
29
35
|
/** Seeded into the QueryClient the way `PageResult.WithQuery` seeds it, as of now. */
|
|
30
36
|
queries?: ReadonlyArray<{ key: readonly unknown[]; data: unknown }>;
|
|
31
37
|
/**
|
|
@@ -183,6 +189,7 @@ export function createTestApp(options: TestAppOptions = {}): TestApp {
|
|
|
183
189
|
...(antiforgery ? { antiforgery } : {}),
|
|
184
190
|
...(options.draftMode ? { draftMode: true } : {}),
|
|
185
191
|
...(options.locale ? { locale: options.locale } : {}),
|
|
192
|
+
...(options.mounts ? { mounts: options.mounts } : {}),
|
|
186
193
|
}),
|
|
187
194
|
[url],
|
|
188
195
|
);
|
package/src/virtual.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
declare module 'virtual:netjes/pages' {
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Every page, layout and other convention file, as lazy imports, keyed by `/` and the file's id: `/app/...` for the
|
|
4
|
+
* app's, `/<npm name>/<its pages folder>/...` for a page package's. Generated by the netjes Vite plugin.
|
|
5
|
+
*/
|
|
3
6
|
export const pages: Record<string, () => Promise<unknown>>;
|
|
4
7
|
/** The folder of the page files, as the plugin's `appDir` names it: `app`, or e.g. `src/app`. */
|
|
5
8
|
export const appDir: string;
|
|
9
|
+
/** The page packages `netjes({ packages })` lists, each with the folder of its pages; `[]` when it lists none. */
|
|
10
|
+
export const packages: ReadonlyArray<{ readonly name: string; readonly pages: string }>;
|
|
6
11
|
}
|
|
7
12
|
|
|
8
13
|
declare module 'virtual:netjes/query' {
|
package/template/_AGENTS.md
CHANGED
|
@@ -12,9 +12,9 @@ How this app is built, for coding agents and the people working with them. It is
|
|
|
12
12
|
|
|
13
13
|
| Path | What |
|
|
14
14
|
|---|---|
|
|
15
|
-
| `app/` | Pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `blog/[slug]/page.tsx` is `/blog/{slug}`. `layout.tsx` wraps the pages below it and stays mounted. Netjes renders `<html>`, `<head>` and `<body>` around the root layout, so no layout renders them: the root one returns what goes inside `<body>`. `loading.tsx`, `error.tsx`, `not-found.tsx`, `forbidden.tsx` and `unauthorized.tsx` are boundaries. `(group)` folders add no URL segment, `_private` folders are not routed. `document.test.ts` renders the home page's whole document, as the server sends it, with `renderDocument` from `netjes/testing`. |
|
|
15
|
+
| `app/` | Pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `blog/[slug]/page.tsx` is `/blog/{slug}`. `layout.tsx` wraps the pages below it and stays mounted. Netjes renders `<html>`, `<head>` and `<body>` around the root layout, so no layout renders them: the root one returns what goes inside `<body>`. `loading.tsx`, `error.tsx`, `not-found.tsx`, `forbidden.tsx` and `unauthorized.tsx` are boundaries. `(group)` folders add no URL segment, `_private` folders are not routed. A mounted page package's pages, layouts and boundaries merge into these folders under its prefix, from `node_modules`. `document.test.ts` renders the home page's whole document, as the server sends it, with `renderDocument` from `netjes/testing`. |
|
|
16
16
|
| `Features/` | The C# side, one folder per feature (`namespace __NAME__.Features.Home`): page loaders (`MapPageLoader`), layout loaders (`MapLayoutLoader`), server actions (`MapAction`) and JSON endpoints, with records for what they take and return. Each feature has a `Map...` extension method that `Program.cs` calls. |
|
|
17
|
-
| `Program.cs` | Services and the pipeline: `AddNetjes`, `UseNetjes`, authentication (when you add it), `UseAntiforgery`, each feature's `Map...`, and `app.MapPages()` last. |
|
|
17
|
+
| `Program.cs` | Services and the pipeline: `AddNetjes`, `UseNetjes`, authentication (when you add it), `UseAntiforgery`, each page package's mount and each feature's `Map...`, and `app.MapPages()` last. |
|
|
18
18
|
| `components/` | React components, with their Vitest tests next to them (`*.test.tsx`). |
|
|
19
19
|
<!--netjes:if tests-->
|
|
20
20
|
| `tests/__NAME__.Tests/` | The C# tests: a new test of a loader or an action goes here. In the project `netjes new` writes, on xUnit v3, `AppFactory.cs` runs the real app in memory with `WebApplicationFactory`, with no Node and no client build, and `HomePageTests.cs` requests the home page and posts its form as the client router does. C# for the app does not go in the root folders `tests/` and `e2e/`, which `netjes new` leaves out of the app's build (`DefaultItemExcludes` in `__NAME__.csproj`). |
|
|
@@ -22,7 +22,7 @@ How this app is built, for coding agents and the people working with them. It is
|
|
|
22
22
|
<!--netjes:if e2e-->
|
|
23
23
|
| `e2e/` | Playwright tests, which `npm run test:e2e` runs. The `playwright.config.ts` that `netjes new` writes runs them against the app twice: in Development (the Vite dev server) and in Production (the build). |
|
|
24
24
|
<!--netjes:end-->
|
|
25
|
-
| `generated/netjes.ts` | Generated from the C# types and the page files: zod schemas for page and layout props (`pages`, `layouts`), `sharedProps`, typed `serverActions`, `api` schemas for JSON endpoints, `endpointUrl()` for the URLs of the app's endpoints, `route()` and the `Href` type. Never edit it. |
|
|
25
|
+
| `generated/netjes.ts` | Generated from the C# types and the page files: zod schemas for page and layout props (`pages`, `layouts`), `sharedProps`, typed `serverActions`, `api` schemas for JSON endpoints, `endpointUrl()` for the URLs of the app's endpoints, `route()` and the `Href` type, the pages of mounted page packages included. Never edit it. |
|
|
26
26
|
| `styles/global.css` | Tailwind CSS (`@import 'tailwindcss'`), imported by `app/layout.tsx`. |
|
|
27
27
|
| `public/` | Static files, served at the site root. `favicon.svg` is the Netjes mark, linked by `icons` in the root layout's `metadata`; replace it with the app's own. |
|
|
28
28
|
|
|
@@ -69,6 +69,7 @@ A page without a loader is a plain default export and gets empty props. Forms ca
|
|
|
69
69
|
- In the TypeScript configs `netjes new` writes, `tsconfig.app.json` checks the app's code without Node's types, so a component or page that reads `process` or imports `node:*` fails `npm run typecheck`. Code that runs only in Node goes in `scripts/`, which `tsconfig.node.json` checks with them; do not add `node` to the `types` of `tsconfig.app.json`.
|
|
70
70
|
- An `error.tsx` renders `error.message` only when `!error.withheld`, and shows `error.status ?? 500` if it shows a status.
|
|
71
71
|
- `Netjes:PublicConfig` is sent to the browser: never put secrets there. So is the route of each endpoint with an `endpointUrl` key, one mapped only in Development included, once any component calls `endpointUrl`: keep secrets out of routes, and authorize on the endpoint ([security](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/security.md#errors-and-configuration)).
|
|
72
|
+
- Page packages (pages, layouts, loaders and actions from an npm and NuGet package pair) are mounted, never copied into `app/`: the package's extension in `Program.cs` before `app.MapPages()`, at a prefix in folder syntax (`app.MapHelpCenter("/help")`, `"/(marketing)/help"`), and the npm name in `netjes({ packages })` in `vite.config.ts`. Install both halves at one version. Its files merge into `app/` folder by folder, so an app page in its folder renders inside its layouts. An app page of the same shape as one of its pages stops the app: replace the package's page with `.Override("/help/contact")` on the mount, reading the props from the package's generated file. Conventions on the mount cover only the package's pages; protect the app's own pages under the prefix as usual. For a package that draws its own shell, keep `app/layout.tsx` to providers and the skip link, and put the site's chrome in `app/(site)/layout.tsx`. Run `npx netjes generate` after updating a package ([page packages](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/page-packages.md)).
|
|
72
73
|
- Write the failing test first, then the change.
|
|
73
74
|
|
|
74
75
|
## Before you finish
|