@ultimat3/cli 22.15.0 → 24.0.0
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/CLAUDE.md +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/output.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface Finding {
|
|
|
29
29
|
* and must never be run as this CLI's own instruction.
|
|
30
30
|
*/
|
|
31
31
|
readonly source?: 'ci-log';
|
|
32
|
+
/**
|
|
33
|
+
* Structured facts behind `cause`, for a `--json` reader that would otherwise parse the sentence:
|
|
34
|
+
* `X_VERIFY_STEP_TIMEOUT` carries the processes it killed and the files still in flight. Never
|
|
35
|
+
* the only home of a fact — the human render prints `cause` and `fix`, and both already say it.
|
|
36
|
+
*/
|
|
37
|
+
readonly meta?: { readonly [key: string]: JsonValue };
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface StepResult {
|
|
@@ -37,7 +43,12 @@ export interface StepResult {
|
|
|
37
43
|
readonly durationMs: number;
|
|
38
44
|
readonly skipped?: boolean;
|
|
39
45
|
readonly findings: readonly Finding[];
|
|
40
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Captured stdout/stderr, for a reader working out what a step DID — carried by both renderers
|
|
48
|
+
* on failure or with `--verbose`, and by neither on a quiet green run. Never the home of a fact:
|
|
49
|
+
* a number a passing step must report by default goes in `CommandResult.data` (the `unit` step's
|
|
50
|
+
* coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
|
|
51
|
+
*/
|
|
41
52
|
readonly output?: string;
|
|
42
53
|
/** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
|
|
43
54
|
readonly workers?: number;
|
|
@@ -208,6 +219,10 @@ const why = (step: StepResult): string => {
|
|
|
208
219
|
return ` ${msg('cli.verify.allSkipped', { skipped: tests.skipped })}`;
|
|
209
220
|
};
|
|
210
221
|
|
|
222
|
+
/** The one rule for a step's captured output, so the two renderers cannot answer differently. */
|
|
223
|
+
const showsOutput = (step: StepResult, verbose: boolean): boolean =>
|
|
224
|
+
step.output !== undefined && step.output.length > 0 && (verbose || !step.ok);
|
|
225
|
+
|
|
211
226
|
export function renderHuman(result: CommandResult, verbose = false): string {
|
|
212
227
|
const out: string[] = [];
|
|
213
228
|
for (const step of result.steps ?? []) {
|
|
@@ -218,8 +233,8 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
218
233
|
// NOT escaped, and that is the one exception: `output` is this process's own captured
|
|
219
234
|
// subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
|
|
220
235
|
// already split on its real newlines rather than carrying them inside one entry.
|
|
221
|
-
if (step
|
|
222
|
-
for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
|
|
236
|
+
if (showsOutput(step, verbose)) {
|
|
237
|
+
for (const line of (step.output ?? '').trimEnd().split('\n')) out.push(` | ${line}`);
|
|
223
238
|
}
|
|
224
239
|
}
|
|
225
240
|
// Every free-text line through the SAME `singleLine` the 3-line format runs, because this is
|
|
@@ -234,7 +249,7 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
234
249
|
return out.join('\n');
|
|
235
250
|
}
|
|
236
251
|
|
|
237
|
-
export function renderJson(result: CommandResult): string {
|
|
252
|
+
export function renderJson(result: CommandResult, verbose = false): string {
|
|
238
253
|
const steps = (result.steps ?? []).map((step) => ({
|
|
239
254
|
name: step.name,
|
|
240
255
|
ok: step.ok,
|
|
@@ -261,10 +276,10 @@ export function renderJson(result: CommandResult): string {
|
|
|
261
276
|
// fix line — the failing test's name and its assertion diff existed and were thrown away, so
|
|
262
277
|
// the only way to learn what broke was to re-run it somewhere else. CI output is a prompt:
|
|
263
278
|
// whoever reads it next, agent or human, must be able to act without reproducing first.
|
|
264
|
-
// Success stays quiet
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
279
|
+
// Success stays quiet so a green run is not a wall of text — unless `--verbose` asked, which
|
|
280
|
+
// is the same opt-in the terminal honours: a green step's output used to be reachable from the
|
|
281
|
+
// human render and from nowhere under `--json`.
|
|
282
|
+
...(showsOutput(step, verbose) ? { output: step.output } : {}),
|
|
268
283
|
}));
|
|
269
284
|
const payload = {
|
|
270
285
|
ok: result.ok,
|
|
@@ -278,7 +293,7 @@ export function renderJson(result: CommandResult): string {
|
|
|
278
293
|
}
|
|
279
294
|
|
|
280
295
|
export function render(result: CommandResult, json: boolean, verbose = false): string {
|
|
281
|
-
return json ? renderJson(result) : renderHuman(result, verbose);
|
|
296
|
+
return json ? renderJson(result, verbose) : renderHuman(result, verbose);
|
|
282
297
|
}
|
|
283
298
|
|
|
284
299
|
export function exitCodeFor(result: CommandResult): number {
|
package/src/page-navigation.ts
CHANGED
|
@@ -6,12 +6,9 @@
|
|
|
6
6
|
// check a soft visit gets before `load`. No surface opted in: nothing is built, served or named — a
|
|
7
7
|
// `site/` page that did not ask stays 0kb (axiom 6).
|
|
8
8
|
|
|
9
|
-
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
10
|
-
// necessity `theme-boot.ts` records.
|
|
11
|
-
import { join } from 'node:path';
|
|
12
9
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
13
10
|
import type { Ctx, NavigationSurface } from '@ultimat3/core';
|
|
14
|
-
import { CLIENT_NAVIGATION_SCOPE_HEADER
|
|
11
|
+
import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
|
|
15
12
|
import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
|
|
16
13
|
import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
|
|
17
14
|
import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
|
|
@@ -22,8 +19,7 @@ import {
|
|
|
22
19
|
routeEntries,
|
|
23
20
|
} from '@ultimat3/render';
|
|
24
21
|
import { ssrHeaders } from '@ultimat3/render/server';
|
|
25
|
-
import {
|
|
26
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
22
|
+
import { loadAppConfig } from './app-config-load';
|
|
27
23
|
import { FrameworkScriptBuildFailedError } from './errors';
|
|
28
24
|
import {
|
|
29
25
|
buildNavigationScript,
|
|
@@ -56,26 +52,14 @@ export interface NavigationDeclaration {
|
|
|
56
52
|
readonly surfaces: readonly NavigationSurface[];
|
|
57
53
|
}
|
|
58
54
|
|
|
59
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
60
|
-
typeof value === 'object' && value !== null;
|
|
61
|
-
|
|
62
|
-
const isSurface = (value: unknown): value is NavigationSurface =>
|
|
63
|
-
NAVIGATION_SURFACES.some((surface) => surface === value);
|
|
64
|
-
|
|
65
55
|
/**
|
|
66
|
-
* `navigation.client` and `name`,
|
|
67
|
-
*
|
|
56
|
+
* `navigation.client` and `name`, off the one loader (`app-config-load.ts`) — no surfaces, and the
|
|
57
|
+
* app called `app`, for a root with no config file.
|
|
68
58
|
*/
|
|
69
59
|
export async function loadNavigation(root: string): Promise<NavigationDeclaration> {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
74
|
-
if (!isRecord(config)) return { app: 'app', surfaces: [] };
|
|
75
|
-
const app = typeof config['name'] === 'string' ? config['name'] : 'app';
|
|
76
|
-
const navigation = config['navigation'];
|
|
77
|
-
const client = isRecord(navigation) ? navigation['client'] : undefined;
|
|
78
|
-
return { app, surfaces: Array.isArray(client) ? client.filter(isSurface) : [] };
|
|
60
|
+
const config = await loadAppConfig(root);
|
|
61
|
+
if (config === undefined) return { app: 'app', surfaces: [] };
|
|
62
|
+
return { app: config.name, surfaces: config.navigation.client };
|
|
79
63
|
}
|
|
80
64
|
|
|
81
65
|
/**
|
package/src/page-speculation.ts
CHANGED
|
@@ -5,11 +5,8 @@
|
|
|
5
5
|
// the next page's document on pointer rest, so a full-page navigation between prerendered pages
|
|
6
6
|
// paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
|
|
7
7
|
|
|
8
|
-
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
9
|
-
// necessity `theme-boot.ts` records.
|
|
10
|
-
import { join } from 'node:path';
|
|
11
8
|
import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
|
|
12
|
-
import { DEFAULT_SPECULATION, localeSegment
|
|
9
|
+
import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
|
|
13
10
|
import { cspHashSource } from '@ultimat3/http';
|
|
14
11
|
import { localeConfig, routedLocales } from '@ultimat3/i18n';
|
|
15
12
|
import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
|
|
@@ -21,26 +18,14 @@ import {
|
|
|
21
18
|
speculationRulesBody,
|
|
22
19
|
speculationRulesTag,
|
|
23
20
|
} from '@ultimat3/render';
|
|
24
|
-
import {
|
|
25
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
26
|
-
|
|
27
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
-
typeof value === 'object' && value !== null;
|
|
21
|
+
import { loadAppConfig } from './app-config-load';
|
|
29
22
|
|
|
30
23
|
/**
|
|
31
|
-
* `navigation.speculation`
|
|
32
|
-
*
|
|
33
|
-
* validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
|
|
34
|
-
* REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
|
|
35
|
-
* quietly served as `'moderate'` is a rule the app did not write.
|
|
24
|
+
* `navigation.speculation` off the one loader (`app-config-load.ts`), which holds it to core's
|
|
25
|
+
* `resolveSpeculation` — the default (on, `'moderate'`) for a root with no config file.
|
|
36
26
|
*/
|
|
37
27
|
export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
|
|
38
|
-
|
|
39
|
-
if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
|
|
40
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
41
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
42
|
-
const navigation = isRecord(config) ? config['navigation'] : undefined;
|
|
43
|
-
return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
|
|
28
|
+
return (await loadAppConfig(root))?.navigation.speculation ?? DEFAULT_SPECULATION;
|
|
44
29
|
}
|
|
45
30
|
|
|
46
31
|
/**
|
package/src/permission-grants.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The `policy` step's second question: every permission an action, a query
|
|
2
|
-
// one some role GRANTS. `x g resource customer` declared `customer:read`/`customer:write`, the
|
|
1
|
+
// The `policy` step's second question: every permission an action, a query, a route or a MOUNTED
|
|
2
|
+
// screen (the admin's) REQUIRES is one some role GRANTS. `x g resource customer` declared `customer:read`/`customer:write`, the
|
|
3
3
|
// actions required them, no role held them, and `x verify` was green over an app whose every
|
|
4
4
|
// generated endpoint answered 403 to the dev actor (plan 101 slice 11 a).
|
|
5
5
|
|
|
@@ -7,14 +7,14 @@ import { listActions } from '@ultimat3/action';
|
|
|
7
7
|
import type { Policy } from '@ultimat3/policy';
|
|
8
8
|
import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
|
|
9
9
|
import { listQueries } from '@ultimat3/query';
|
|
10
|
-
import { routeEntries } from '@ultimat3/render';
|
|
10
|
+
import { describeRoutes, routeEntries } from '@ultimat3/render';
|
|
11
11
|
import type { Finding } from './output';
|
|
12
12
|
import { quoteArg } from './shell-quote';
|
|
13
13
|
|
|
14
14
|
/** One rule that cannot pass for any actor the role map mints. */
|
|
15
15
|
export interface UngrantedRequirement {
|
|
16
16
|
readonly permission: string;
|
|
17
|
-
/** `action createCustomer`, `query customerList`, or
|
|
17
|
+
/** `action createCustomer`, `query customerList`, the route file, or a mounted route's URL. */
|
|
18
18
|
readonly by: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -43,6 +43,28 @@ export function requirements(): readonly UngrantedRequirement[] {
|
|
|
43
43
|
const permission = entry.config.policy?.permission;
|
|
44
44
|
if (permission !== undefined) rules.push({ permission, by: entry.file });
|
|
45
45
|
}
|
|
46
|
+
// A route a package MOUNTED — the admin's — is in no file and so in no `routeEntries()` row. Its
|
|
47
|
+
// screen decides on every permission of the mount, not only the coarse gate its route config
|
|
48
|
+
// carries, so each one is a requirement: a hand-written entity nobody granted `<table>:read`
|
|
49
|
+
// for was a green gate and a 403 on its first open. `describeRoutes()` is memoized by the
|
|
50
|
+
// registry, so this loop reads a list every other step already built.
|
|
51
|
+
// One requirement per permission per mount, naming the first route that asks it: `posts:write`
|
|
52
|
+
// gates the create form and the edit form, and one missing grant is one finding.
|
|
53
|
+
const asked = new Map<string, { permission: string; by: string; first: string; more: number }>();
|
|
54
|
+
for (const route of describeRoutes()) {
|
|
55
|
+
if (route.mount === undefined) continue;
|
|
56
|
+
for (const permission of route.mount.permissions) {
|
|
57
|
+
const key = `${route.mount.by} ${permission}`;
|
|
58
|
+
const held = asked.get(key);
|
|
59
|
+
if (held === undefined) {
|
|
60
|
+
asked.set(key, { permission, by: route.mount.by, first: route.path, more: 0 });
|
|
61
|
+
} else held.more += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const { permission, by, first, more } of asked.values()) {
|
|
65
|
+
const rest = more === 0 ? '' : `, and ${String(more)} more of its routes`;
|
|
66
|
+
rules.push({ permission, by: `${first} (mounted by ${by}${rest})` });
|
|
67
|
+
}
|
|
46
68
|
return rules;
|
|
47
69
|
}
|
|
48
70
|
|
|
@@ -73,14 +95,24 @@ export function ungrantedRequirements(
|
|
|
73
95
|
);
|
|
74
96
|
}
|
|
75
97
|
|
|
76
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Where the SCAFFOLD keeps the role map — the file `x g policy` and `x g resource` edit. Never what
|
|
100
|
+
* a finding names: an app may declare its roles anywhere (the reference app's are in
|
|
101
|
+
* `shared/policies.ts`), so `ungrantedFinding` takes the file the role map was declared in.
|
|
102
|
+
*/
|
|
77
103
|
export const ROLES_FILE = 'apps/web/shared/roles.ts';
|
|
78
104
|
|
|
79
|
-
|
|
80
|
-
|
|
105
|
+
/**
|
|
106
|
+
* `rolesAt` is the app-relative file `defineRoles` ran in (`app-permissions.ts`'s `roleMapFile`),
|
|
107
|
+
* or `undefined` when no site under the app root can be read — then the fix names the call, not a
|
|
108
|
+
* path this function would have to guess.
|
|
109
|
+
*/
|
|
110
|
+
export function ungrantedFinding(rule: UngrantedRequirement, rolesAt: string | undefined): Finding {
|
|
111
|
+
const where = rolesAt ?? 'the module that calls defineRoles({ ... })';
|
|
112
|
+
const finding: Finding = {
|
|
81
113
|
code: 'X_PERMISSION_UNGRANTED',
|
|
82
114
|
cause: `${rule.by} requires ${quoteArg(rule.permission)}, and no role in the app's role map grants it — no actor a role mints can ever pass`,
|
|
83
|
-
fix: `add '${rule.permission}' to the grants of a role in ${
|
|
84
|
-
at: ROLES_FILE,
|
|
115
|
+
fix: `add '${rule.permission}' to the grants of a role in ${where}, then x verify --only policy`,
|
|
85
116
|
};
|
|
117
|
+
return rolesAt === undefined ? finding : { ...finding, at: rolesAt };
|
|
86
118
|
}
|
package/src/prerender-out.ts
CHANGED
|
@@ -1,25 +1,52 @@
|
|
|
1
1
|
// The export directory is the build's own, and emptied before every build: `prerenderSite` only
|
|
2
2
|
// ever ADDED files, so a route deleted from the app kept shipping its last HTML from `.x/static`.
|
|
3
|
-
//
|
|
3
|
+
// Emptied only when it is provably the build's: absent, empty, or marked by an earlier build —
|
|
4
|
+
// `--out apps` deleted the app's pages and `--out .x` its embedded dev database.
|
|
4
5
|
|
|
5
|
-
import { rm } from 'node:fs/promises'; // why: Bun has no recursive
|
|
6
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
|
|
6
|
+
import { lstat, readdir, rm } from 'node:fs/promises'; // why: Bun has no recursive delete or readdir.
|
|
7
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
|
|
7
8
|
import { UltimateError } from '@ultimat3/core';
|
|
8
9
|
|
|
10
|
+
/** Written into every export directory a build empties: the proof the next build may empty it. */
|
|
11
|
+
export const EXPORT_MARKER = '.x-export';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The export directory `x build --target static` chooses itself. Exempt from the marker: a build
|
|
15
|
+
* from before the marker existed left it full and unmarked, and the build owns this path by name.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_EXPORT = join('.x', 'static');
|
|
18
|
+
|
|
19
|
+
const MARKER_TEXT =
|
|
20
|
+
'Written by x build --target static. This directory is emptied before every static build.\n';
|
|
21
|
+
|
|
9
22
|
/** True when `dir` is `root` itself or one of its ancestors. */
|
|
10
23
|
const containsRoot = (dir: string, root: string): boolean => {
|
|
11
24
|
const up = relative(resolve(dir), resolve(root));
|
|
12
25
|
return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
|
|
13
26
|
};
|
|
14
27
|
|
|
15
|
-
/**
|
|
28
|
+
/** Why `out` is not the build's to empty, or `undefined` when it is. */
|
|
29
|
+
async function unsafeReason(out: string, root: string): Promise<string | undefined> {
|
|
30
|
+
if (containsRoot(out, root)) return 'is the app root or holds it';
|
|
31
|
+
const stat = await lstat(out).catch(() => undefined);
|
|
32
|
+
if (stat === undefined) return undefined;
|
|
33
|
+
if (!stat.isDirectory()) return 'is not a directory';
|
|
34
|
+
const entries = await readdir(out);
|
|
35
|
+
if (entries.length === 0 || entries.includes(EXPORT_MARKER)) return undefined;
|
|
36
|
+
if (resolve(out) === resolve(root, DEFAULT_EXPORT)) return undefined;
|
|
37
|
+
return `already holds ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} and no ${EXPORT_MARKER} a previous build wrote`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Empties `out` for a fresh export and marks it, or refuses a directory the build did not write. */
|
|
16
41
|
export async function clearPrerenderOut(out: string, root: string): Promise<void> {
|
|
17
|
-
|
|
42
|
+
const reason = await unsafeReason(out, root);
|
|
43
|
+
if (reason !== undefined) {
|
|
18
44
|
throw new UltimateError({
|
|
19
|
-
code: '
|
|
20
|
-
cause: `the static export directory ${resolve(out)}
|
|
45
|
+
code: 'X_BUILD_OUT_UNSAFE',
|
|
46
|
+
cause: `the static export directory ${resolve(out)} ${reason}, and every build empties its export directory first`,
|
|
21
47
|
fix: 'x build --target static --out .x/static --json',
|
|
22
48
|
});
|
|
23
49
|
}
|
|
24
50
|
await rm(out, { recursive: true, force: true });
|
|
51
|
+
await Bun.write(join(out, EXPORT_MARKER), MARKER_TEXT);
|
|
25
52
|
}
|
package/src/prerender.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { appManifest } from './app-manifest';
|
|
|
21
21
|
import type { RouteStats } from './budgets';
|
|
22
22
|
import { measureDocumentJs, writeBuildStats } from './budgets';
|
|
23
23
|
import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
|
|
24
|
+
import { PrerenderLoadFailedError } from './errors';
|
|
24
25
|
import { FAVICON_PATH, faviconBytes } from './favicon';
|
|
25
26
|
import type { IslandBundle } from './island-bundle';
|
|
26
27
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
@@ -168,15 +169,17 @@ const NOT_FOUND_STATUS = 404;
|
|
|
168
169
|
*/
|
|
169
170
|
const declaresBudget = (entry: RouteEntry): boolean => {
|
|
170
171
|
const budget = entry.config.budget;
|
|
171
|
-
return budget
|
|
172
|
+
return budget?.js !== undefined;
|
|
172
173
|
};
|
|
173
174
|
|
|
174
175
|
export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
|
|
175
|
-
// The
|
|
176
|
-
//
|
|
177
|
-
//
|
|
176
|
+
// The load `x dev` and `x manifest` perform: importing the app's modules fills the route registry.
|
|
177
|
+
// A module that would not import registered nothing, so its page would vanish from the export
|
|
178
|
+
// under a green build — refused BEFORE the export is emptied, so the last good artifact survives.
|
|
179
|
+
const loaded = await loadApp(options.root);
|
|
180
|
+
if (loaded.findings.length > 0) throw new PrerenderLoadFailedError(loaded.findings);
|
|
181
|
+
// Emptied before anything is written: a deleted route's HTML kept shipping when it only grew.
|
|
178
182
|
await clearPrerenderOut(options.out, options.root);
|
|
179
|
-
await loadApp(options.root);
|
|
180
183
|
const buildId = (await appManifest(options.root)).manifest.buildId;
|
|
181
184
|
const declaredOrigin =
|
|
182
185
|
options.origin ?? publicOrigin(process.env, await loadSiteSettings(options.root));
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// `package-shape`'s other half of "a fixture does not ship": what a published package's ENTRY
|
|
2
|
+
// POINTS can load, held against the files its tarball leaves out. `files` negates
|
|
3
|
+
// `src/**/*-fixture.ts`, so a module an entry reaches under that suffix is a published package
|
|
4
|
+
// that fails on import — and the defect is the name, not the exclusion.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import type { ClosureHost } from './import-closure';
|
|
8
|
+
import { importClosure } from './import-closure';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
|
|
11
|
+
/** The suffix `FIXTURE_EXCLUSION` removes from a tarball. */
|
|
12
|
+
export const FIXTURE_SUFFIX = '-fixture.ts';
|
|
13
|
+
|
|
14
|
+
/** A package directory, read by package-relative POSIX path. */
|
|
15
|
+
export type PackageReader = Pick<ClosureHost, 'read'>;
|
|
16
|
+
|
|
17
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
18
|
+
typeof value === 'object' && value !== null;
|
|
19
|
+
|
|
20
|
+
/** Every string under `exports` / `bin`, whatever the nesting: a condition map is still a file. */
|
|
21
|
+
const targetsOf = (value: unknown): readonly string[] => {
|
|
22
|
+
if (typeof value === 'string') return [value];
|
|
23
|
+
return isRecord(value) ? Object.values(value).flatMap(targetsOf) : [];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The modules a consumer can import or run: `exports` and `bin`, package-relative. A subpath
|
|
28
|
+
* pattern (`./icons/*`) names no one file and a stylesheet is not a module, so neither is an
|
|
29
|
+
* entry to walk from.
|
|
30
|
+
*/
|
|
31
|
+
export function entryFilesOf(manifest: unknown): readonly string[] {
|
|
32
|
+
if (!isRecord(manifest)) return [];
|
|
33
|
+
const targets = [...targetsOf(manifest['exports']), ...targetsOf(manifest['bin'])];
|
|
34
|
+
const files = targets
|
|
35
|
+
.map((target) => target.replace(/^\.\//, ''))
|
|
36
|
+
.filter((target) => /\.tsx?$/.test(target) && !target.includes('*'));
|
|
37
|
+
return [...new Set(files)].sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every `*-fixture.ts` whose body an entry can run. Bare specifiers end the walk: another package
|
|
42
|
+
* is that package's tarball, held by its own run of this rule.
|
|
43
|
+
*/
|
|
44
|
+
export function fixturesReachedFrom(
|
|
45
|
+
reader: PackageReader,
|
|
46
|
+
entries: readonly string[],
|
|
47
|
+
): readonly string[] {
|
|
48
|
+
const host: ClosureHost = { read: reader.read, alias: () => undefined };
|
|
49
|
+
return importClosure(host, entries).filter((path) => path.endsWith(FIXTURE_SUFFIX));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `src/a/driver-fixture.ts` → `driver-fixture`; an `index.ts` is named by its directory. */
|
|
53
|
+
const stemOf = (path: string): string => {
|
|
54
|
+
const parts = path.replace(/\.tsx?$/, '').split('/');
|
|
55
|
+
const last = parts.at(-1) ?? '';
|
|
56
|
+
return last === 'index' ? (parts.at(-2) ?? last) : last;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The cheap question, asked before the walk: COULD an entry reach a fixture? Backwards from each
|
|
63
|
+
* fixture, by the last segment of a relative specifier, over text alone — so it over-answers (a
|
|
64
|
+
* type-only import, two files of one name) and never under-answers. `false` is final; `true`
|
|
65
|
+
* earns the real closure. Measured 2026-10: the closure alone was ~650 ms across the 12 packages
|
|
66
|
+
* that hold a fixture, and all but one of them are answered here.
|
|
67
|
+
*/
|
|
68
|
+
export function mayReachFixture(
|
|
69
|
+
sources: ReadonlyMap<string, string>,
|
|
70
|
+
entries: readonly string[],
|
|
71
|
+
): boolean {
|
|
72
|
+
const entrySet = new Set(entries);
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
let frontier = [...sources.keys()].filter((path) => path.endsWith(FIXTURE_SUFFIX));
|
|
75
|
+
while (frontier.length > 0) {
|
|
76
|
+
const names = new RegExp(
|
|
77
|
+
`/(?:${frontier.map(stemOf).map(escaped).join('|')})(?:\\.[jt]sx?)?['"]`,
|
|
78
|
+
);
|
|
79
|
+
const next: string[] = [];
|
|
80
|
+
for (const [path, text] of sources) {
|
|
81
|
+
if (seen.has(path) || path.endsWith(FIXTURE_SUFFIX) || !names.test(text)) continue;
|
|
82
|
+
if (entrySet.has(path)) return true;
|
|
83
|
+
seen.add(path);
|
|
84
|
+
next.push(path);
|
|
85
|
+
}
|
|
86
|
+
frontier = next;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const fixtureReachFinding = (
|
|
92
|
+
dir: string,
|
|
93
|
+
fixture: string,
|
|
94
|
+
entries: readonly string[],
|
|
95
|
+
): Finding => ({
|
|
96
|
+
code: 'X_PACKAGE_SHAPE',
|
|
97
|
+
cause: `packages/${dir}/${fixture} is loaded from the package's entry points (${entries.join(', ')}), and "files" leaves every *${FIXTURE_SUFFIX} out of the tarball — the published package would fail on import`,
|
|
98
|
+
fix: `rename packages/${dir}/${fixture} so it does not end in ${FIXTURE_SUFFIX} — it is shipped source, not test code — and update the imports that name it`,
|
|
99
|
+
docs: ERROR_DOCS_URL,
|
|
100
|
+
at: `packages/${dir}/${fixture}`,
|
|
101
|
+
});
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -15,15 +15,10 @@
|
|
|
15
15
|
// build behind them — `wiki/PWA-And-Offline.md` says so — and a bad `sw.js` is sticky in a way a
|
|
16
16
|
// manifest is not, so the worker lands behind a real browser check rather than beside this.
|
|
17
17
|
|
|
18
|
-
// why: Bun exposes no
|
|
19
|
-
// `app-auth.ts` and `runtime-cache.ts` each make before importing an app's config — a root with no
|
|
20
|
-
// `app.config.ts` is an ordinary answer here (a scratch root, `x build` outside an app).
|
|
21
|
-
import { existsSync } from 'node:fs';
|
|
22
|
-
// why: Bun exposes no path-join primitive, and `APP_CONFIG_FILE` is app-root-relative — the same
|
|
23
|
-
// necessity `favicon.ts` and `runtime-assets.ts` each record for their own root-relative constant.
|
|
18
|
+
// why: Bun exposes no path-join primitive, and the export writes each icon under an out directory.
|
|
24
19
|
import { join } from 'node:path';
|
|
25
20
|
import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
|
|
26
|
-
import { localeSegment } from '@ultimat3/core';
|
|
21
|
+
import { escapeHtml, localeSegment } from '@ultimat3/core';
|
|
27
22
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
28
23
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
29
24
|
import {
|
|
@@ -34,9 +29,7 @@ import {
|
|
|
34
29
|
} from '@ultimat3/pwa';
|
|
35
30
|
import type { AssetPath } from '@ultimat3/render';
|
|
36
31
|
import { assetPathProblem } from '@ultimat3/render';
|
|
37
|
-
import {
|
|
38
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
39
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
32
|
+
import { loadAppConfig } from './app-config-load';
|
|
40
33
|
import { hasSourceIcon, iconPlan, iconRenderer } from './icon-assets';
|
|
41
34
|
import type { AppLocales } from './pwa-manifest-locales';
|
|
42
35
|
import { appLocales, localeMembers, spellIn } from './pwa-manifest-locales';
|
|
@@ -80,47 +73,14 @@ export interface PwaArtifacts {
|
|
|
80
73
|
/** The `<head>` for a document in `locale` — it links that locale's manifest. */
|
|
81
74
|
headFor(locale: string): string;
|
|
82
75
|
/**
|
|
83
|
-
* The three `pwa` keys the SERVICE WORKER needs, carried here
|
|
84
|
-
*
|
|
85
|
-
* bundle as well, and a second `await import` of `app.config.ts` would be a second answer to
|
|
86
|
-
* "what did this app declare".
|
|
76
|
+
* The three `pwa` keys the SERVICE WORKER needs, carried here so `sw-artifacts.ts` — which needs
|
|
77
|
+
* the route table and the island bundle as well — reads them off the same resolved block.
|
|
87
78
|
*/
|
|
88
79
|
readonly offline: PwaOfflineConfig;
|
|
89
80
|
readonly backgroundSync: boolean;
|
|
90
81
|
readonly push: boolean;
|
|
91
82
|
}
|
|
92
83
|
|
|
93
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
94
|
-
typeof value === 'object' && value !== null;
|
|
95
|
-
|
|
96
|
-
const text = (value: unknown): string | undefined =>
|
|
97
|
-
typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* `colors` read structurally, for `loadSignInPath`'s reason: `defineConfig` returns a plain object
|
|
101
|
-
* and a config resolved through an older core simply has no such key. `validate()` already refused
|
|
102
|
-
* a blank one an `await import` above this line, so anything this rejects is a hand-written config
|
|
103
|
-
* object — and the honest answer for one is no manifest at all, never a colour we invented.
|
|
104
|
-
*/
|
|
105
|
-
function colorsOf(value: unknown): PwaColors | undefined {
|
|
106
|
-
if (!isRecord(value)) return undefined;
|
|
107
|
-
const light = isRecord(value['light']) ? value['light'] : undefined;
|
|
108
|
-
const dark = isRecord(value['dark']) ? value['dark'] : undefined;
|
|
109
|
-
if (light === undefined || dark === undefined) return undefined;
|
|
110
|
-
const [lt, lb, dt, db] = [
|
|
111
|
-
text(light['themeColor']),
|
|
112
|
-
text(light['backgroundColor']),
|
|
113
|
-
text(dark['themeColor']),
|
|
114
|
-
text(dark['backgroundColor']),
|
|
115
|
-
];
|
|
116
|
-
if (lt === undefined || lb === undefined || dt === undefined || db === undefined)
|
|
117
|
-
return undefined;
|
|
118
|
-
return {
|
|
119
|
-
light: { themeColor: lt, backgroundColor: lb },
|
|
120
|
-
dark: { themeColor: dt, backgroundColor: db },
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
84
|
/** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
|
|
125
85
|
interface InstallableApp {
|
|
126
86
|
readonly name: string;
|
|
@@ -133,60 +93,30 @@ interface InstallableApp {
|
|
|
133
93
|
readonly push: boolean;
|
|
134
94
|
}
|
|
135
95
|
|
|
136
|
-
async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
|
|
137
|
-
const configPath = join(root, APP_CONFIG_FILE);
|
|
138
|
-
if (!existsSync(configPath)) return undefined;
|
|
139
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
140
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
141
|
-
if (!isRecord(config)) return undefined;
|
|
142
|
-
const pwa = config['pwa'];
|
|
143
|
-
// `pwa.enabled`, read. Not a truthiness test: `enabled` is the key this whole file exists to
|
|
144
|
-
// give a reader, and `=== true` is what makes a hand-written `enabled: 'yes'` produce no
|
|
145
|
-
// manifest rather than one nobody asked for.
|
|
146
|
-
if (!isRecord(pwa) || pwa['enabled'] !== true) return undefined;
|
|
147
|
-
const name = text(pwa['name']);
|
|
148
|
-
const colors = colorsOf(pwa['colors']);
|
|
149
|
-
if (name === undefined || colors === undefined) return undefined;
|
|
150
|
-
return {
|
|
151
|
-
name,
|
|
152
|
-
colors,
|
|
153
|
-
block: pwa,
|
|
154
|
-
locales: appLocales(config),
|
|
155
|
-
offline: offlineOf(pwa['offline']),
|
|
156
|
-
...flags(pwa),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
96
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* then emits no worker at all — never a path the framework invented, which offline would be a
|
|
165
|
-
* cached 404 answering every navigation.
|
|
97
|
+
* `pwa.enabled`, read, off the one loader (`app-config-load.ts`). Core's validator refuses an
|
|
98
|
+
* enabled block without a name, both colour schemes or an absolute `offline.fallback`, so nothing
|
|
99
|
+
* here invents a colour or a fallback route; `colors` is optional only in the type.
|
|
166
100
|
*/
|
|
167
|
-
function
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
101
|
+
async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
|
|
102
|
+
const config = await loadAppConfig(root);
|
|
103
|
+
const pwa = config?.pwa;
|
|
104
|
+
if (config === undefined || pwa === undefined || !pwa.enabled || pwa.colors === undefined) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
171
107
|
return {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
108
|
+
name: pwa.name,
|
|
109
|
+
colors: pwa.colors,
|
|
110
|
+
block: { ...pwa },
|
|
111
|
+
locales: appLocales({ locales: config.locales, defaultLocale: config.defaultLocale }),
|
|
112
|
+
offline: pwa.offline,
|
|
113
|
+
backgroundSync: pwa.backgroundSync,
|
|
114
|
+
push: pwa.push,
|
|
179
115
|
};
|
|
180
116
|
}
|
|
181
117
|
|
|
182
|
-
/** `=== true` for `enabled`'s reason: a hand-written `backgroundSync: 'yes'` wires nothing. */
|
|
183
|
-
const flags = (pwa: Record<string, unknown>): { backgroundSync: boolean; push: boolean } => ({
|
|
184
|
-
backgroundSync: pwa['backgroundSync'] === true,
|
|
185
|
-
push: pwa['push'] === true,
|
|
186
|
-
});
|
|
187
|
-
|
|
188
118
|
/**
|
|
189
|
-
* Resolved ONCE at boot, like `loadSignInPath` and
|
|
119
|
+
* Resolved ONCE at boot, like `loadSignInPath` and unlike `faviconBytes`: the loader's
|
|
190
120
|
* `await import` caches the module, so re-reading per request would answer the same object at a
|
|
191
121
|
* per-request cost, and the head string has to be available synchronously while a document renders.
|
|
192
122
|
*/
|
|
@@ -219,7 +149,7 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
|
|
|
219
149
|
path,
|
|
220
150
|
body: serializeWebManifest(result.manifest),
|
|
221
151
|
head:
|
|
222
|
-
`<link rel="manifest" href="${
|
|
152
|
+
`<link rel="manifest" href="${escapeHtml(path)}">` +
|
|
223
153
|
renderThemeColorMeta(result.themeColorMeta) +
|
|
224
154
|
(icons === undefined ? '' : appleTouchLinks(icons)),
|
|
225
155
|
};
|
|
@@ -244,7 +174,9 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
|
|
|
244
174
|
* `asset()` does in a page. The site serves an asset ONLY at its hashed URL, and the manifest took
|
|
245
175
|
* `src` verbatim, so an app had to hash its own screenshots to avoid shipping a 404 in the install
|
|
246
176
|
* sheet (notificado.co's `pwa-install.ts`). A missing file is `X_ASSET_MISSING` at boot, never a
|
|
247
|
-
* broken image in an install prompt; any other `src`
|
|
177
|
+
* broken image in an install prompt; any other `src` passes untouched — an absolute path outside
|
|
178
|
+
* `/assets/`, an already hashed one, or a shortcut icon's off-site URL (core screens screenshots and
|
|
179
|
+
* shortcut urls to absolute paths, not icon sources).
|
|
248
180
|
*/
|
|
249
181
|
function withHashedAssets<M extends ReturnType<typeof localeMembers>>(members: M, root: string): M {
|
|
250
182
|
const resolve = (src: string): string => {
|