@zerotal/core 1.15.0 → 1.15.1

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/CHANGELOG.md CHANGED
@@ -8,6 +8,67 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.1] — 2026-09-13
12
+
13
+ ### Added
14
+
15
+ - **`registerViewFileRouteResolver` is public.** It is what turns on server-rendered
16
+ file-route pages — a route file's default export rendered to HTML and wrapped in the
17
+ nearest `_layout` — and it carried an internal marker, so nothing outside the framework
18
+ could call it. Both features were implemented, tested, and described in `docs/view.md` as
19
+ automatic, while being unreachable through the public API.
20
+
21
+ Promoted rather than removed: the marker was wrong, not the design. It stays opt-in, and
22
+ the docblock now says why — it claims every `.tsx` route file's default export, which an
23
+ app using file routes for plain handlers has not asked for, and it turns on a directory
24
+ walk per scanned route. `docs/view.md` documents the call and the `_layout` convention,
25
+ including that `_layout` is read by its **default** export where `_middleware` is read by
26
+ a named one.
27
+
28
+ ### Fixed
29
+
30
+ - **Four test suites left their fixture trees on disk after every run.** Each builds a
31
+ temporary package or asset tree and removes it in `afterAll`, and the removal reported
32
+ success without removing anything: on Bun 1.4.0 under Windows, a recursive `rm()` from
33
+ `node:fs/promises` resolves and deletes nothing when the path carries a `"./"` prefix,
34
+ which all four used. Written bare, the same call works.
35
+
36
+ Test-only, but it was not harmless. The abandoned trees contain deliberately
37
+ non-conformant fixture packages, `lint:ci` lints anything under `packages/**`, and the
38
+ gate is a ratchet against a committed count — so a full test run left the next lint run
39
+ one or two warnings over baseline, and the regression pointed at whatever change happened
40
+ to be in the tree. Previously read as fallout from interrupted runs, because an
41
+ interrupted run skips the hook and produces the same evidence.
42
+
43
+ - **A `_layout` file that could not apply rendered its pages without one.** The same
44
+ fail-open the `_middleware` loader had, one function up in the same file, and found by
45
+ looking for the shape rather than by hitting it: an import error was swallowed together
46
+ with the not-found case under "component resolution is best-effort". Best-effort is
47
+ right for _absence_ and wrong for a file that exists and threw — a `_layout.tsx` with a
48
+ typo rendered every page beneath it without its chrome, with no error and no log, and on
49
+ a hot-reload without chrome it had a moment earlier.
50
+
51
+ Absence stays silent and the walk continues outward, as before. An import error stops
52
+ the boot with the path and the original error, and a `_layout` that default-exports no
53
+ component now says so rather than silently handing those pages an outer directory's
54
+ layout instead.
55
+
56
+ Worth noting alongside the `_middleware` fix in 1.15.0: these two sibling conventions
57
+ disagreed about export shape — `_layout` reads `default` and ignored a named export,
58
+ `_middleware` read the named export and ignored `default` — and each failed open on the
59
+ other's spelling. That disagreement is why `export default` was the natural guess in a
60
+ `_middleware.ts` to begin with.
61
+
62
+ - **`zt doctor` says when its outside-in probes did not run.** The response-header and
63
+ WebSocket-transport probes only run with `--url`, and they are the two checks that can
64
+ see what no in-process check can. Being silent about skipping them meant the people who
65
+ most needed them were the ones with no way to learn they existed — a team running
66
+ `doctor` after every deploy for months never saw the duplicate-header check.
67
+
68
+ A line, not a new default: auto-probing `app.url` would print "✓ No duplicated security
69
+ headers" for a site that was merely unreachable, which is the same false confidence the
70
+ secure-headers check was fixed for in 1.15.0.
71
+
11
72
  ## [1.15.0] — 2026-09-04
12
73
 
13
74
  ### Changed — **BREAKING**
package/api-surface.md CHANGED
@@ -3783,6 +3783,8 @@ function Fragment = ({ children }: { children?: unknown;}) => SafeHtml
3783
3783
 
3784
3784
  function Raw = ({ html }: { html: string;}) => SafeHtml
3785
3785
 
3786
+ function registerViewFileRouteResolver = () => void
3787
+
3786
3788
  function safe = (html: string) => SafeHtml
3787
3789
 
3788
3790
  type Children = unknown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -39,6 +39,7 @@ export class DoctorCommand extends Command {
39
39
 
40
40
  const probeFailures = await this._probeTransport(app);
41
41
  const headerFindings = await this._probeHeaders();
42
+ this._noteSkippedProbes();
42
43
 
43
44
  const warns = report.filter((e) => e.result.status === "warn").length + headerFindings.warnings;
44
45
  const fails =
@@ -104,6 +105,30 @@ export class DoctorCommand extends Command {
104
105
  * are a warning, because they are a conflict waiting for someone to edit one
105
106
  * side.
106
107
  */
108
+ /**
109
+ * Say that the outside-in probes did not run, when they did not.
110
+ *
111
+ * They are the two checks that can see what no in-process check can — a header the
112
+ * proxy also sets, a WebSocket the proxy drops — and they are behind a flag, so the
113
+ * people who most need them are the ones who do not know the flag exists. A team
114
+ * running `doctor` after every deploy for months never saw the duplicate-header
115
+ * check and had no way to find out it was there.
116
+ *
117
+ * A line, not a default. Auto-probing `app.url` would print "✓ No duplicated
118
+ * security headers" for a site that was simply unreachable, which is a worse
119
+ * failure than the silence: it is the same false confidence the secure-headers
120
+ * check was fixed for.
121
+ */
122
+ private _noteSkippedProbes(): void {
123
+ if (this.flags["url"]) return;
124
+ this.newLine();
125
+ this.dim(
126
+ "Not checked: response headers and WebSocket transport, which are only visible " +
127
+ "from outside.\n Add --url https://your-site to read the deployed app through " +
128
+ "its proxy.",
129
+ );
130
+ }
131
+
107
132
  private async _probeHeaders(): Promise<{ failures: number; warnings: number }> {
108
133
  const url = this.flags["url"] as string | undefined;
109
134
  if (!url) return { failures: 0, warnings: 0 };
@@ -414,18 +414,44 @@ async function _collectLayout(
414
414
  const layoutPath = dir
415
415
  ? join(baseDir, dir, `_layout${extension}`)
416
416
  : join(baseDir, `_layout${extension}`);
417
+ // No file at this spelling is the ordinary case — most directories have no
418
+ // layout, and the walk continues outward to find the nearest one that does.
419
+ if (!(await Bun.file(layoutPath).exists())) continue;
420
+
421
+ const href = pathToFileURL(layoutPath).href;
422
+ const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
423
+
424
+ let loadedModule: { default?: unknown };
417
425
  try {
418
- if (await Bun.file(layoutPath).exists()) {
419
- const href = pathToFileURL(layoutPath).href;
420
- const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
421
- const loadedModule = (await import(importUrl)) as { default?: unknown };
422
- if (typeof loadedModule.default === "function") {
423
- return loadedModule.default as ViewComponent;
424
- }
425
- }
426
- } catch {
427
- // A page module that fails to import is skipped — component resolution is best-effort.
426
+ loadedModule = (await import(importUrl)) as { default?: unknown };
427
+ } catch (error) {
428
+ // This was swallowed with the not-found case, under "component resolution is
429
+ // best-effort". Best-effort is right for *absence*; it is not right for a file
430
+ // that exists and threw. A `_layout.tsx` with a typo in it rendered every page
431
+ // beneath without its chrome — no error, no log, and on a hot-reload a layout
432
+ // that was there a moment ago. Same failure the `_middleware` loader had, one
433
+ // function up, minus the part where the missing thing was a guard.
434
+ throw new Error(
435
+ `[Zerotal] Failed to import ${layoutPath}: ` +
436
+ `${error instanceof Error ? error.message : String(error)}\n` +
437
+ `Pages under this directory would render without their layout, so boot ` +
438
+ `stops here. Fix the file, or delete it if the directory needs no layout.`,
439
+ { cause: error },
440
+ );
428
441
  }
442
+
443
+ if (typeof loadedModule.default === "function") {
444
+ return loadedModule.default as ViewComponent;
445
+ }
446
+
447
+ // The file exists and exports no component. Continuing the walk would silently
448
+ // hand these pages an outer directory's layout instead — plausible-looking output
449
+ // that is not what the file says should happen.
450
+ throw new Error(
451
+ `[Zerotal] ${layoutPath} does not default-export a component, so it lays out ` +
452
+ `nothing.\nA layout file is read by its default export:\n` +
453
+ ` export default (ctx, { children }) => \`<main>\${children}</main>\`;`,
454
+ );
429
455
  }
430
456
  }
431
457
  return undefined;
@@ -19,12 +19,36 @@ function isClass(fn: Function): boolean {
19
19
  }
20
20
 
21
21
  /**
22
- * Opt into rendering `.tsx`/`.jsx` file-route pages: a route file's default
23
- * export that is a view component (or any function tagged via the view marker)
24
- * is rendered to HTML, wrapped in the nearest `_layout`, and registered as a GET
25
- * route. Call from a provider's `onRegister()` before file routes are scanned.
22
+ * Opt into server-rendered file-route pages.
26
23
  *
27
- * @internal
24
+ * With this registered, a route file whose default export is a view component
25
+ * (or any function tagged by {@link definePage}) is rendered to HTML, wrapped in
26
+ * the nearest `_layout`, and registered as a `GET` route. Without it, neither
27
+ * happens: the default export is registered as an ordinary file handler and the
28
+ * `_layout` convention is switched off entirely.
29
+ *
30
+ * Call it before file routes are scanned — a provider's `onRegister()`, or
31
+ * `bootstrap/app.ts` before `fileBasedRouting()`:
32
+ *
33
+ * ```ts
34
+ * // bootstrap/app.ts
35
+ * import { registerViewFileRouteResolver } from "@zerotal/core/view";
36
+ *
37
+ * registerViewFileRouteResolver();
38
+ *
39
+ * export default Application.create({ providers }).fileBasedRouting({
40
+ * web: basePath("app/views"),
41
+ * });
42
+ * ```
43
+ *
44
+ * **Why it is opt-in rather than on by default.** It claims every `.tsx` route
45
+ * file's default export, which an app using file routes for plain handlers has
46
+ * not asked for — and it turns on `_layout` discovery, which walks the directory
47
+ * tree on every scanned route. An app that wants JSX pages says so.
48
+ *
49
+ * It carried an internal marker until 1.15, which made both features unreachable
50
+ * through the public API while the documentation described them as automatic.
51
+ * The marker was wrong, not the design.
28
52
  */
29
53
  export function registerViewFileRouteResolver(): void {
30
54
  enableFileRouteLayouts();