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/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) {
@@ -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
- /** The page files, keyed `/app/...` as `import.meta.glob` keys them. Default: the app's, from the `netjes()` plugin. */
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, 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 string[], appDir: string): AppFiles {
85
- const folders = new Map<string, Map<string, 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
- if (!conventions.has(parsed.name)) conventions.set(parsed.name, file);
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 string[], appDir: string): string[] {
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.folders.length === 0));
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. Throws for a file it cannot render: one not in `files`, a layout, or a page in a private
171
- * folder or under a parallel route slot.
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(page: string, options: DocumentOptions, files: readonly string[], appDir: string): InitialPayload {
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(paths, appDir))}.`;
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 parsed = parse(page, appDir);
182
- if (!parsed) throw new Error(`netjes: renderDocument cannot render ${page}, which is not in the app's directory, ${appDir}.`);
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.folders.length > 0)) {
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(paths, appDir), parsed.folders, appDir, options);
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
- /** Every page, layout and other convention file, keyed `/app/...`, as lazy imports. Generated by the netjes Vite plugin. */
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' {
@@ -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