@ultimat3/cli 24.0.0 → 25.1.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +19 -30
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +36 -15
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
package/src/page-speculation.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
|
|
9
9
|
import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
|
|
10
10
|
import { cspHashSource } from '@ultimat3/http';
|
|
11
|
-
import {
|
|
11
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
12
12
|
import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
|
|
13
13
|
import {
|
|
14
|
-
|
|
14
|
+
describePages,
|
|
15
15
|
renderHead,
|
|
16
16
|
routeEntries,
|
|
17
17
|
speculationPattern,
|
|
@@ -67,15 +67,18 @@ export interface PageSpeculationInput {
|
|
|
67
67
|
readonly client: readonly NavigationSurface[];
|
|
68
68
|
readonly entries?: readonly RouteEntry[];
|
|
69
69
|
readonly described?: readonly RouteDescriptor[];
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The NON-default routed locales' URL segments — `otherLocaleSegments(appLocaleSet)`, the set the
|
|
72
|
+
* manifest, the worker and the prerender read. Every caller passes it: no build reads the
|
|
73
|
+
* ambient locale config.
|
|
74
|
+
*/
|
|
75
|
+
readonly localeSegments: readonly string[];
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.filter((segment) => segment !== fallback);
|
|
78
|
+
/** Every routed locale but the default, as the URL segment a prefix is spelled in. */
|
|
79
|
+
export const otherLocaleSegments = (set: AppLocaleSet): readonly string[] => {
|
|
80
|
+
const fallback = localeSegment(set.defaultLocale);
|
|
81
|
+
return set.locales.map(localeSegment).filter((segment) => segment !== fallback);
|
|
79
82
|
};
|
|
80
83
|
|
|
81
84
|
/**
|
|
@@ -86,9 +89,9 @@ const otherLocaleSegments = (): readonly string[] => {
|
|
|
86
89
|
export function pageSpeculation(input: PageSpeculationInput): PageSpeculation | undefined {
|
|
87
90
|
if (input.config.prefetch === false) return undefined;
|
|
88
91
|
const entries = input.entries ?? routeEntries();
|
|
89
|
-
const described = new Map((input.described ??
|
|
92
|
+
const described = new Map((input.described ?? describePages()).map((one) => [one.file, one]));
|
|
90
93
|
const client = new Set<string>(input.client);
|
|
91
|
-
const segments = input.localeSegments
|
|
94
|
+
const segments = input.localeSegments;
|
|
92
95
|
const include = entries
|
|
93
96
|
.filter((entry) => {
|
|
94
97
|
const facts = described.get(entry.file);
|
package/src/parse.ts
CHANGED
|
@@ -8,14 +8,6 @@ import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './err
|
|
|
8
8
|
// anywhere while still refusing with a fix line a shell reads as one argument.
|
|
9
9
|
import { quoteArg } from './shell-quote';
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
|
|
13
|
-
* package's exported surface and removing it would be a major for a rename. One implementation
|
|
14
|
-
* behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
|
|
15
|
-
* used to carry. New callers import `nearestName` from core.
|
|
16
|
-
*/
|
|
17
|
-
export const nearest = nearestName;
|
|
18
|
-
|
|
19
11
|
export type FlagValue = string | boolean;
|
|
20
12
|
|
|
21
13
|
export interface FlagSpec {
|
package/src/permission-grants.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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 {
|
|
10
|
+
import { describePages, routeEntries } from '@ultimat3/render';
|
|
11
11
|
import type { Finding } from './output';
|
|
12
12
|
import { quoteArg } from './shell-quote';
|
|
13
13
|
|
|
@@ -46,12 +46,12 @@ export function requirements(): readonly UngrantedRequirement[] {
|
|
|
46
46
|
// A route a package MOUNTED — the admin's — is in no file and so in no `routeEntries()` row. Its
|
|
47
47
|
// screen decides on every permission of the mount, not only the coarse gate its route config
|
|
48
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. `
|
|
49
|
+
// for was a green gate and a 403 on its first open. `describePages()` is memoized by the
|
|
50
50
|
// registry, so this loop reads a list every other step already built.
|
|
51
51
|
// One requirement per permission per mount, naming the first route that asks it: `posts:write`
|
|
52
52
|
// gates the create form and the edit form, and one missing grant is one finding.
|
|
53
53
|
const asked = new Map<string, { permission: string; by: string; first: string; more: number }>();
|
|
54
|
-
for (const route of
|
|
54
|
+
for (const route of describePages()) {
|
|
55
55
|
if (route.mount === undefined) continue;
|
|
56
56
|
for (const permission of route.mount.permissions) {
|
|
57
57
|
const key = `${route.mount.by} ${permission}`;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// One answer to "this host path, written with `/`". `relative()` and `Bun.Glob` answer in the
|
|
2
|
+
// host's separator, so on Windows a `\` reached an import specifier (`x fix` wrote
|
|
3
|
+
// `import '..\\foo'`, which breaks every Linux teammate), a fix line and a sort key. Every such
|
|
4
|
+
// result passes through here — `bun run posix-relative` refuses a `relative(` that does not.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API; `node:path` owns `relative`, and a test hands in its `win32` half to
|
|
7
|
+
// prove the Windows answer on a Linux runner.
|
|
8
|
+
import * as hostPath from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one method `posixRelative` asks of a path module — structural, not `PlatformPath`: the
|
|
12
|
+
* scaffold's typecheck resolves `node:path` without that name exported (TS2305).
|
|
13
|
+
*/
|
|
14
|
+
export interface RelativeHost {
|
|
15
|
+
readonly relative: (from: string, to: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `path` with every `\` written as `/`. Unconditional rather than keyed to the host's `sep`: a key,
|
|
20
|
+
* a specifier and a fix line are POSIX on every host, and a Windows-shaped path handed to a Linux
|
|
21
|
+
* process (a CI log, a test fixture) must come out the same as it would on Windows.
|
|
22
|
+
*/
|
|
23
|
+
export function toPosix(path: string): string {
|
|
24
|
+
return path.replaceAll('\\', '/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `relative(from, to)` in POSIX form. `host` is the platform's own path module but for a test. */
|
|
28
|
+
export function posixRelative(from: string, to: string, host: RelativeHost = hostPath): string {
|
|
29
|
+
return toPosix(host.relative(from, to));
|
|
30
|
+
}
|
package/src/prerender-locales.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// paths and files `renderStatic` computed; every other locale's are served at `/<locale>/<path>`
|
|
3
3
|
// and written to `<locale>/<file>` — the layout every static host resolves with no rewrite rule.
|
|
4
4
|
|
|
5
|
-
import { localeSegment,
|
|
5
|
+
import { localeSegment, localizePath } from '@ultimat3/core';
|
|
6
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
6
7
|
import type { StaticArtifact } from '@ultimat3/render/server';
|
|
7
8
|
|
|
8
9
|
/** A `StaticArtifact` placed for one locale: its served path and its file under `<locale>/`. */
|
|
@@ -10,8 +11,7 @@ export type LocalizedArtifact = StaticArtifact & { readonly locale: string };
|
|
|
10
11
|
|
|
11
12
|
/** The default's artifacts first, so a report lists the unprefixed page before its translations. */
|
|
12
13
|
export async function localizedArtifacts(
|
|
13
|
-
locales:
|
|
14
|
-
defaultLocale: string,
|
|
14
|
+
{ locales, defaultLocale }: AppLocaleSet,
|
|
15
15
|
renderIn: (locale: string) => Promise<readonly StaticArtifact[]>,
|
|
16
16
|
): Promise<readonly LocalizedArtifact[]> {
|
|
17
17
|
const out: LocalizedArtifact[] = [];
|
|
@@ -21,7 +21,9 @@ export async function localizedArtifacts(
|
|
|
21
21
|
out.push({
|
|
22
22
|
...artifact,
|
|
23
23
|
locale,
|
|
24
|
-
path: isDefault
|
|
24
|
+
path: isDefault
|
|
25
|
+
? artifact.path
|
|
26
|
+
: localizePath(artifact.path, locale, locales, defaultLocale),
|
|
25
27
|
outputPath: isDefault
|
|
26
28
|
? artifact.outputPath
|
|
27
29
|
: `${localeSegment(locale)}/${artifact.outputPath}`,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// What `x build --target static` reports: every page it wrote, every route it did not and why, and
|
|
2
|
+
// where the stats and the inventory landed. Split from `prerender.ts`, which decides what is
|
|
3
|
+
// written; this file only names the shapes the report carries.
|
|
4
|
+
|
|
5
|
+
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
6
|
+
|
|
7
|
+
export interface PrerenderedPage {
|
|
8
|
+
/** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
|
|
9
|
+
readonly route: string;
|
|
10
|
+
/** The URL the page is served at — `/en/pricing` for a non-default locale. */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
/** The locale the page was rendered in. */
|
|
13
|
+
readonly locale: string;
|
|
14
|
+
/** Relative to `out`, POSIX — under `<locale>/` for a non-default locale. */
|
|
15
|
+
readonly file: string;
|
|
16
|
+
readonly hash: string;
|
|
17
|
+
readonly bytes: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PrerenderReport {
|
|
21
|
+
readonly out: string;
|
|
22
|
+
readonly buildId: string;
|
|
23
|
+
readonly pages: readonly PrerenderedPage[];
|
|
24
|
+
/**
|
|
25
|
+
* Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
|
|
26
|
+
* #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
|
|
27
|
+
* screenshot tool pointed at the directory filed "the island did not mount" against a route that
|
|
28
|
+
* had never been in the artifact. The reason is what tells an author whether an edit exists.
|
|
29
|
+
*/
|
|
30
|
+
readonly skipped: readonly SkippedRoute[];
|
|
31
|
+
/**
|
|
32
|
+
* Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
|
|
33
|
+
* the gate then reports for each; this is the half that says WHY, which a per-route finding read
|
|
34
|
+
* off a stats file cannot know.
|
|
35
|
+
*/
|
|
36
|
+
readonly unmeasured: readonly UnmeasuredRoute[];
|
|
37
|
+
/** Where the measured stats landed, for the `budgets` gate step to read. */
|
|
38
|
+
readonly stats: string;
|
|
39
|
+
/** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
|
|
40
|
+
readonly report: string;
|
|
41
|
+
/** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
|
|
42
|
+
readonly islands: readonly string[];
|
|
43
|
+
/** Surface stylesheets emitted, one file each — the CSS half of the same question. */
|
|
44
|
+
readonly styles: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* What the service worker could not express, and what its precache manifest weighs too much of.
|
|
47
|
+
*
|
|
48
|
+
* `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
|
|
49
|
+
* `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
|
|
50
|
+
* that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
|
|
51
|
+
* has to reach the build's own report. Empty for an app with no service worker.
|
|
52
|
+
*/
|
|
53
|
+
readonly serviceWorkerWarnings: readonly string[];
|
|
54
|
+
/** What this build could not get right on its own — today, a production build with no origin. */
|
|
55
|
+
readonly warnings: readonly string[];
|
|
56
|
+
/** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
|
|
57
|
+
readonly origin: string;
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// How the build weighs an app/ page it renders only to measure: as the document a request is
|
|
2
|
+
// SERVED — the sync head and page boot both boots compose (`pageSync`), and the runtime chunk that
|
|
3
|
+
// boot makes unfetched. An unscoped render measured a runtime chunk no scoped page downloads and
|
|
4
|
+
// never the boot every one of them does, so `/feed` and `/runs` were budgets about nothing (#505).
|
|
5
|
+
|
|
6
|
+
import type { ClientSyncHead } from '@ultimat3/render';
|
|
7
|
+
import { loadAppConfig } from './app-config-load';
|
|
8
|
+
import type { MeasureOptions } from './budgets';
|
|
9
|
+
import type { IslandBundle } from './island-bundle';
|
|
10
|
+
import { isRuntimeChunk } from './island-runtime';
|
|
11
|
+
import { pageSync } from './page-sync';
|
|
12
|
+
import { realtimeConfigOf } from './runtime-realtime';
|
|
13
|
+
import { PAGE_BOOT_BASE_PATH } from './worker-bundle';
|
|
14
|
+
|
|
15
|
+
/** What a served-document measurement adds: to the document's options, and to the measurer's. */
|
|
16
|
+
export interface ServedMeasure {
|
|
17
|
+
readonly document: {
|
|
18
|
+
readonly sync?: ClientSyncHead;
|
|
19
|
+
readonly persisted?: () => readonly string[];
|
|
20
|
+
};
|
|
21
|
+
readonly measure: MeasureOptions;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `realtime.enabled: false` serves no sync head and no boot (`pageSync`), and then the measurement
|
|
26
|
+
* adds nothing: such a page's realtime islands load the runtime chunk, and are charged for it.
|
|
27
|
+
*/
|
|
28
|
+
export async function servedMeasure(
|
|
29
|
+
root: string,
|
|
30
|
+
buildId: string,
|
|
31
|
+
islands: IslandBundle,
|
|
32
|
+
): Promise<ServedMeasure> {
|
|
33
|
+
const sync = await pageSync(
|
|
34
|
+
root,
|
|
35
|
+
process.env,
|
|
36
|
+
buildId,
|
|
37
|
+
realtimeConfigOf(await loadAppConfig(root)),
|
|
38
|
+
);
|
|
39
|
+
return {
|
|
40
|
+
document: {
|
|
41
|
+
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
42
|
+
persisted: sync.persisted,
|
|
43
|
+
},
|
|
44
|
+
measure: {
|
|
45
|
+
served: new Map(sync.scripts.map((script) => [script.url, script.code])),
|
|
46
|
+
boot: {
|
|
47
|
+
prefix: `${PAGE_BOOT_BASE_PATH}/`,
|
|
48
|
+
supplies: new Set(islands.shared.map((one) => one.url).filter(isRuntimeChunk)),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/prerender.ts
CHANGED
|
@@ -5,21 +5,21 @@
|
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
ctxOf,
|
|
9
9
|
DEFAULT_ENVIRONMENT,
|
|
10
10
|
isUltimateError,
|
|
11
|
+
localizePath,
|
|
11
12
|
renderThrowable,
|
|
12
13
|
runWithContext,
|
|
13
14
|
tryResolveEnvironment,
|
|
14
15
|
} from '@ultimat3/core';
|
|
15
|
-
import { localeConfig, localizedPath, routedLocales } from '@ultimat3/i18n';
|
|
16
16
|
import type { RouteEntry } from '@ultimat3/render';
|
|
17
|
-
import {
|
|
17
|
+
import { describePages, routeEntries } from '@ultimat3/render';
|
|
18
18
|
import { renderStatic } from '@ultimat3/render/server';
|
|
19
19
|
import { loadApp } from './app-load';
|
|
20
20
|
import { appManifest } from './app-manifest';
|
|
21
21
|
import type { RouteStats } from './budgets';
|
|
22
|
-
import { measureDocumentJs, writeBuildStats } from './budgets';
|
|
22
|
+
import { measureDocumentJs, routeStatsRow, writeBuildStats } from './budgets';
|
|
23
23
|
import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
|
|
24
24
|
import { PrerenderLoadFailedError } from './errors';
|
|
25
25
|
import { FAVICON_PATH, faviconBytes } from './favicon';
|
|
@@ -27,13 +27,16 @@ import type { IslandBundle } from './island-bundle';
|
|
|
27
27
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
28
28
|
import { measureDatabase } from './measure-database';
|
|
29
29
|
import { measurePaths } from './measure-paths';
|
|
30
|
-
import { measureScope, withAppUrl } from './measure-scope';
|
|
30
|
+
import { measurementActorLabel, measureScope, withAppUrl } from './measure-scope';
|
|
31
31
|
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
32
|
-
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
32
|
+
import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
|
|
33
33
|
import { localizedArtifacts } from './prerender-locales';
|
|
34
34
|
import { clearPrerenderOut } from './prerender-out';
|
|
35
|
+
import type { PrerenderedPage, PrerenderReport } from './prerender-report';
|
|
36
|
+
import type { ServedMeasure } from './prerender-served';
|
|
37
|
+
import { servedMeasure } from './prerender-served';
|
|
35
38
|
import { loadPwaArtifacts, writePwaIcons } from './pwa-artifacts';
|
|
36
|
-
import { routeDocument } from './runtime-render';
|
|
39
|
+
import { routeDocument, servedDocument } from './runtime-render';
|
|
37
40
|
import { writeSiteAssets } from './site-assets';
|
|
38
41
|
import { loadSiteSettings, originWarning, publicOrigin } from './site-config';
|
|
39
42
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
@@ -48,9 +51,13 @@ import {
|
|
|
48
51
|
serviceWorkerRegistration,
|
|
49
52
|
} from './sw-artifacts';
|
|
50
53
|
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
54
|
+
import { loadThemeBrand } from './theme-brand';
|
|
55
|
+
import { writeErrorLine } from './write-line';
|
|
51
56
|
|
|
57
|
+
export type { PrerenderedPage, PrerenderReport } from './prerender-report';
|
|
52
58
|
// Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
|
|
53
|
-
// carries it, and this file already imports that module.
|
|
59
|
+
// carries it, and this file already imports that module. The report's own shapes are
|
|
60
|
+
// `prerender-report.ts`'s, re-exported here for the same reason: callers ask this module.
|
|
54
61
|
export type { UnmeasuredRoute };
|
|
55
62
|
|
|
56
63
|
/**
|
|
@@ -75,59 +82,6 @@ export interface PrerenderOptions {
|
|
|
75
82
|
readonly origin?: string;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
export interface PrerenderedPage {
|
|
79
|
-
/** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
|
|
80
|
-
readonly route: string;
|
|
81
|
-
/** The URL the page is served at — `/en/pricing` for a non-default locale. */
|
|
82
|
-
readonly path: string;
|
|
83
|
-
/** The locale the page was rendered in. */
|
|
84
|
-
readonly locale: string;
|
|
85
|
-
/** Relative to `out`, POSIX — under `<locale>/` for a non-default locale. */
|
|
86
|
-
readonly file: string;
|
|
87
|
-
readonly hash: string;
|
|
88
|
-
readonly bytes: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface PrerenderReport {
|
|
92
|
-
readonly out: string;
|
|
93
|
-
readonly buildId: string;
|
|
94
|
-
readonly pages: readonly PrerenderedPage[];
|
|
95
|
-
/**
|
|
96
|
-
* Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
|
|
97
|
-
* #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
|
|
98
|
-
* screenshot tool pointed at the directory filed "the island did not mount" against a route that
|
|
99
|
-
* had never been in the artifact. The reason is what tells an author whether an edit exists.
|
|
100
|
-
*/
|
|
101
|
-
readonly skipped: readonly SkippedRoute[];
|
|
102
|
-
/**
|
|
103
|
-
* Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
|
|
104
|
-
* the gate then reports for each; this is the half that says WHY, which a per-route finding read
|
|
105
|
-
* off a stats file cannot know.
|
|
106
|
-
*/
|
|
107
|
-
readonly unmeasured: readonly UnmeasuredRoute[];
|
|
108
|
-
/** Where the measured stats landed, for the `budgets` gate step to read. */
|
|
109
|
-
readonly stats: string;
|
|
110
|
-
/** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
|
|
111
|
-
readonly report: string;
|
|
112
|
-
/** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
|
|
113
|
-
readonly islands: readonly string[];
|
|
114
|
-
/** Surface stylesheets emitted, one file each — the CSS half of the same question. */
|
|
115
|
-
readonly styles: readonly string[];
|
|
116
|
-
/**
|
|
117
|
-
* What the service worker could not express, and what its precache manifest weighs too much of.
|
|
118
|
-
*
|
|
119
|
-
* `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
|
|
120
|
-
* `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
|
|
121
|
-
* that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
|
|
122
|
-
* has to reach the build's own report. Empty for an app with no service worker.
|
|
123
|
-
*/
|
|
124
|
-
readonly serviceWorkerWarnings: readonly string[];
|
|
125
|
-
/** What this build could not get right on its own — today, a production build with no origin. */
|
|
126
|
-
readonly warnings: readonly string[];
|
|
127
|
-
/** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
|
|
128
|
-
readonly origin: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
85
|
/**
|
|
132
86
|
* The heaviest thing the document actually boots, named by the source file an author can open.
|
|
133
87
|
* An island chunk is content-addressed, so its URL says nothing on its own — mapping it back
|
|
@@ -187,11 +141,12 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
187
141
|
// Written to stderr as well as returned: an app's `prerender.ts` prints its report as ONE JSON
|
|
188
142
|
// line on stdout, and a build that parses it must not meet a warning inside it.
|
|
189
143
|
const warnings = originWarning(tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT, declaredOrigin);
|
|
190
|
-
for (const warning of warnings)
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
const
|
|
144
|
+
for (const warning of warnings) writeErrorLine(`warning: ${warning}`);
|
|
145
|
+
// The app's locales as `loadApp` read them, ONCE, off `@ultimat3/i18n`'s one reader — the same
|
|
146
|
+
// answer the PWA manifest and the service worker below are built from. The default first, so
|
|
147
|
+
// every other pass writes beside a tree that already holds the unprefixed one.
|
|
148
|
+
const appLocales = loaded.locales;
|
|
149
|
+
const { locales, defaultLocale } = appLocales;
|
|
195
150
|
const pages: PrerenderedPage[] = [];
|
|
196
151
|
const skipped: SkippedRoute[] = [];
|
|
197
152
|
const routes: RouteStats[] = [];
|
|
@@ -238,6 +193,8 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
238
193
|
// names it either.
|
|
239
194
|
const pwa = await loadPwaArtifacts(options.root);
|
|
240
195
|
const theme = themeBoot(await loadThemeMode(options.root));
|
|
196
|
+
// The brand the served process carries, so an exported page and a served one paint alike.
|
|
197
|
+
const brand = await loadThemeBrand(options.root);
|
|
241
198
|
// The client router, for the surfaces that opted in: written BEFORE any document names it, for
|
|
242
199
|
// the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
|
|
243
200
|
// the route (the interactivity the app asked for), so it must be there to be weighed.
|
|
@@ -247,6 +204,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
247
204
|
const speculation = pageSpeculation({
|
|
248
205
|
config: await loadSpeculation(options.root),
|
|
249
206
|
client: declared.surfaces,
|
|
207
|
+
localeSegments: otherLocaleSegments(appLocales),
|
|
250
208
|
});
|
|
251
209
|
if (navigation.script !== undefined) {
|
|
252
210
|
await Bun.write(join(options.out, navigation.script.url.slice(1)), navigation.script.code);
|
|
@@ -292,11 +250,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
292
250
|
// build. One context for the build, `role: 'web'` because that is the role serving these
|
|
293
251
|
// documents, and this build's own id so a component reading `ctx.buildId` stamps the artifact
|
|
294
252
|
// with the id the report and the stats carry.
|
|
295
|
-
// ONE PER LOCALE, and the locale is the context's: `
|
|
253
|
+
// ONE PER LOCALE, and the locale is the context's: `ctxOf` defaults it to core's `en`, so a
|
|
296
254
|
// Spanish-default site was prerendered in English — `<html lang="en">` over English copy — while
|
|
297
255
|
// the served process answered Spanish. Each `site/` page is rendered once per routed locale.
|
|
298
256
|
const contexts = new Map(
|
|
299
|
-
locales.map((locale) => [locale,
|
|
257
|
+
locales.map((locale) => [locale, ctxOf({ role: 'web', buildId, locale })]),
|
|
300
258
|
);
|
|
301
259
|
// A SECOND scope, for the branch below that renders only to weigh (`measure-scope.ts`): a
|
|
302
260
|
// request context holding the app's measurement actor, with the app's own API answered in
|
|
@@ -307,23 +265,32 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
307
265
|
const measure = await measureScope({ origin, buildId });
|
|
308
266
|
// Started on the first route that needs weighing, and released however the loop ends.
|
|
309
267
|
const database = measureDatabase(options.root);
|
|
268
|
+
const documentOptions = {
|
|
269
|
+
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
270
|
+
themeHead: theme.head,
|
|
271
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
272
|
+
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
273
|
+
origin,
|
|
274
|
+
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
275
|
+
...(pwa === undefined
|
|
276
|
+
? {}
|
|
277
|
+
: { pwaHead: (locale: string) => pwa.headFor(locale) + (swHead ?? '') }),
|
|
278
|
+
};
|
|
310
279
|
const render = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
|
|
311
|
-
routeDocument(entry, data,
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
: { pwaHead: (locale: string) => pwa.headFor(locale) + (swHead ?? '') }),
|
|
320
|
-
});
|
|
280
|
+
routeDocument(entry, data, documentOptions);
|
|
281
|
+
// The weigh-only branch measures the document a request is SERVED (`prerender-served.ts`):
|
|
282
|
+
// composed on the first route that needs it, so a build weighing none builds no page boot.
|
|
283
|
+
let weighing: Promise<ServedMeasure> | undefined;
|
|
284
|
+
const served = (): Promise<ServedMeasure> => {
|
|
285
|
+
weighing ??= servedMeasure(options.root, buildId, islands);
|
|
286
|
+
return weighing;
|
|
287
|
+
};
|
|
321
288
|
const document = (
|
|
322
289
|
locale: string,
|
|
323
290
|
entry: RouteEntry,
|
|
324
291
|
data: { url: string; params: Record<string, string> },
|
|
325
292
|
) =>
|
|
326
|
-
runWithContext(contexts.get(locale) ??
|
|
293
|
+
runWithContext(contexts.get(locale) ?? ctxOf({ role: 'web', buildId, locale }), () =>
|
|
327
294
|
render(entry, data),
|
|
328
295
|
);
|
|
329
296
|
|
|
@@ -352,16 +319,19 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
352
319
|
let row: RouteStats | undefined;
|
|
353
320
|
for (const { path, params } of plan.paths) {
|
|
354
321
|
const data = { url: new URL(path, origin).href, params };
|
|
355
|
-
const
|
|
356
|
-
const
|
|
322
|
+
const as = await served();
|
|
323
|
+
const html = await withAppUrl(origin, () =>
|
|
324
|
+
measure.run(() =>
|
|
325
|
+
servedDocument(entry, data, measure.actor, {
|
|
326
|
+
...documentOptions,
|
|
327
|
+
...as.document,
|
|
328
|
+
buildId,
|
|
329
|
+
}),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
const measured = await measureDocumentJs(html, options.out, as.measure);
|
|
357
333
|
if (row !== undefined && measured.jsBytes <= row.jsBytes) continue;
|
|
358
|
-
|
|
359
|
-
row = {
|
|
360
|
-
path: entry.path,
|
|
361
|
-
jsBytes: measured.jsBytes,
|
|
362
|
-
frameworkJsBytes: measured.frameworkBytes,
|
|
363
|
-
...(chain === undefined ? {} : { heaviestChain: chain }),
|
|
364
|
-
};
|
|
334
|
+
row = routeStatsRow(entry.path, measured, heaviestSource(islands, measured.entries));
|
|
365
335
|
}
|
|
366
336
|
if (row !== undefined) routes.push(row);
|
|
367
337
|
} catch (error) {
|
|
@@ -377,16 +347,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
377
347
|
...(isUltimateError(error)
|
|
378
348
|
? { code: error.code, cause: error.cause, fix: error.fix }
|
|
379
349
|
: {}),
|
|
350
|
+
actor: measurementActorLabel(measure.actor),
|
|
380
351
|
});
|
|
381
352
|
}
|
|
382
353
|
continue;
|
|
383
354
|
}
|
|
384
|
-
const artifacts = await localizedArtifacts(
|
|
355
|
+
const artifacts = await localizedArtifacts(appLocales, (locale) =>
|
|
385
356
|
renderStatic(
|
|
386
357
|
entry,
|
|
387
358
|
({ path, params }) =>
|
|
388
359
|
document(locale, entry, {
|
|
389
|
-
url: new URL(
|
|
360
|
+
url: new URL(localizePath(path, locale, locales, defaultLocale), origin).href,
|
|
390
361
|
params,
|
|
391
362
|
}),
|
|
392
363
|
{ buildId },
|
|
@@ -431,14 +402,8 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
431
402
|
bytes,
|
|
432
403
|
assets: measured.entries.map((entry) => entry.url),
|
|
433
404
|
});
|
|
434
|
-
const chain = heaviestSource(islands, measured.entries);
|
|
435
405
|
if (heaviest !== undefined && heaviest.jsBytes >= measured.jsBytes) continue;
|
|
436
|
-
heaviest =
|
|
437
|
-
path: entry.path,
|
|
438
|
-
jsBytes: measured.jsBytes,
|
|
439
|
-
frameworkJsBytes: measured.frameworkBytes,
|
|
440
|
-
...(chain === undefined ? {} : { heaviestChain: chain }),
|
|
441
|
-
};
|
|
406
|
+
heaviest = routeStatsRow(entry.path, measured, heaviestSource(islands, measured.entries));
|
|
442
407
|
}
|
|
443
408
|
if (heaviest !== undefined) routes.push(heaviest);
|
|
444
409
|
}
|
|
@@ -455,7 +420,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
455
420
|
: serviceWorkerArtifacts({
|
|
456
421
|
pwa,
|
|
457
422
|
buildId,
|
|
458
|
-
routes:
|
|
423
|
+
routes: describePages(),
|
|
459
424
|
islands,
|
|
460
425
|
styles,
|
|
461
426
|
documents,
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
|
|
|
21
21
|
import { escapeHtml, localeSegment } from '@ultimat3/core';
|
|
22
22
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
23
23
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
24
|
+
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
24
25
|
import {
|
|
25
26
|
appleTouchLinks,
|
|
26
27
|
generateWebManifest,
|
|
@@ -79,6 +80,11 @@ export interface PwaArtifacts {
|
|
|
79
80
|
readonly offline: PwaOfflineConfig;
|
|
80
81
|
readonly backgroundSync: boolean;
|
|
81
82
|
readonly push: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* The routed locales the manifests above were built from, default first — `appLocaleSet`'s one
|
|
85
|
+
* answer, carried here so the service worker prefixes exactly the locales the manifests name.
|
|
86
|
+
*/
|
|
87
|
+
readonly locales: AppLocales;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
|
|
@@ -108,7 +114,7 @@ async function loadInstallable(root: string): Promise<InstallableApp | undefined
|
|
|
108
114
|
name: pwa.name,
|
|
109
115
|
colors: pwa.colors,
|
|
110
116
|
block: { ...pwa },
|
|
111
|
-
locales: appLocales(
|
|
117
|
+
locales: appLocales(await appLocaleSet(root)),
|
|
112
118
|
offline: pwa.offline,
|
|
113
119
|
backgroundSync: pwa.backgroundSync,
|
|
114
120
|
push: pwa.push,
|
|
@@ -161,6 +167,7 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
|
|
|
161
167
|
offline: app.offline,
|
|
162
168
|
backgroundSync: app.backgroundSync,
|
|
163
169
|
push: app.push,
|
|
170
|
+
locales: app.locales,
|
|
164
171
|
body: primary.body,
|
|
165
172
|
head: primary.head,
|
|
166
173
|
manifests,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// routed locale from this; the `pwa` block is read structurally, for `colorsOf`'s reason there.
|
|
4
4
|
|
|
5
5
|
import { localeSegment, localizePath } from '@ultimat3/core';
|
|
6
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
6
7
|
import type {
|
|
7
8
|
ManifestIcon,
|
|
8
9
|
ManifestScreenshot,
|
|
@@ -10,7 +11,7 @@ import type {
|
|
|
10
11
|
WebManifestInput,
|
|
11
12
|
} from '@ultimat3/pwa';
|
|
12
13
|
|
|
13
|
-
/** The app's locales as
|
|
14
|
+
/** The app's locales as its catalogs declare them (`defineCatalogs`), default first. */
|
|
14
15
|
export interface AppLocales {
|
|
15
16
|
readonly routed: readonly string[];
|
|
16
17
|
readonly fallback: string;
|
|
@@ -23,15 +24,14 @@ const strings = (value: unknown): readonly string[] =>
|
|
|
23
24
|
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* The manifests' locales, off `appLocaleSet`'s answer (`@ultimat3/i18n/app-catalogs`) — an app
|
|
28
|
+
* with no catalogs is `UNDECLARED_LOCALES`, the framework default alone. The manifest's `lang` was
|
|
29
|
+
* the generator's hardcoded `'en'` for an `es-co` app (notificado.co, 22.3.2). Deduplicated by URL
|
|
30
|
+
* segment, the unit a locale prefix is spelled in.
|
|
29
31
|
*/
|
|
30
|
-
export function appLocales(
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const fallback = typeof named === 'string' && named !== '' ? named : (declared[0] ?? 'en');
|
|
34
|
-
const others = declared.filter((locale) => localeSegment(locale) !== localeSegment(fallback));
|
|
32
|
+
export function appLocales(set: AppLocaleSet): AppLocales {
|
|
33
|
+
const fallback = set.defaultLocale;
|
|
34
|
+
const others = set.locales.filter((locale) => localeSegment(locale) !== localeSegment(fallback));
|
|
35
35
|
return { routed: [fallback, ...others], fallback };
|
|
36
36
|
}
|
|
37
37
|
|
package/src/retry-memo.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface RetryMemo<T> {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* `start` runs at most once per SUCCESS. `@ultimat3/db`'s `
|
|
18
|
+
* `start` runs at most once per SUCCESS. `@ultimat3/db`'s `pgliteClient` states the rule this
|
|
19
19
|
* generalises — "a failed boot must not be cached" — and the clearing handler is attached at
|
|
20
20
|
* creation for the reason that matters: it therefore runs ahead of every caller's own `await`
|
|
21
21
|
* continuation, so by the time anyone sees the rejection the slot is already empty and the next
|