From 935b3103dd691ef5aa03619d1787d552c488dd07 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sun, 12 Jul 2026 16:54:22 +0530 Subject: [PATCH] feat: add safe project upgrades and production performance fixes --- .publish/ai/package.json | 2 +- .publish/authz/package.json | 2 +- .publish/cli/README.md | 6 + .publish/cli/package.json | 22 +-- .publish/compiler/package.json | 2 +- .publish/core/package.json | 2 +- .publish/csr/package.json | 4 +- .publish/db/package.json | 2 +- .publish/dev-server/package.json | 26 +-- .publish/encryption/package.json | 2 +- .publish/i18n/package.json | 4 +- .publish/jwt/package.json | 2 +- .publish/mobile/package.json | 4 +- .publish/native/package.json | 2 +- .publish/oauth/package.json | 2 +- .publish/pubsub/package.json | 2 +- .publish/queue/package.json | 2 +- .publish/reactive/package.json | 2 +- .publish/router/package.json | 6 +- .publish/ssr/package.json | 4 +- .publish/styles/package.json | 4 +- .publish/test/package.json | 2 +- .publish/tracking/package.json | 2 +- .publish/ui/package.json | 4 +- .publish/uploader/package.json | 4 +- .publish/validation/package.json | 2 +- packages/ai/package.json | 2 +- packages/authz/package.json | 2 +- packages/cli/README.md | 6 + packages/cli/package.json | 2 +- packages/cli/src/ai-guide.ts | 1 + packages/cli/src/build.ts | 9 +- packages/cli/src/index.ts | 7 +- packages/cli/src/update-notifier.ts | 83 ++++++++ packages/cli/src/update.ts | 179 +++++++++++++++++- packages/cli/test/update-notifier.test.ts | 37 ++++ packages/cli/test/update.test.ts | 37 ++++ packages/compiler/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/context.ts | 2 + packages/csr/package.json | 2 +- packages/db/package.json | 2 +- packages/dev-server/package.json | 2 +- packages/dev-server/src/prod.ts | 5 + packages/dev-server/src/runtime.ts | 48 ++++- .../test/performance-runtime.test.ts | 17 ++ packages/encryption/package.json | 2 +- packages/i18n/package.json | 2 +- packages/jwt/package.json | 2 +- packages/mobile/package.json | 2 +- packages/native/package.json | 2 +- packages/oauth/package.json | 2 +- packages/pubsub/package.json | 2 +- packages/queue/package.json | 2 +- packages/reactive/package.json | 2 +- packages/router/package.json | 2 +- packages/ssr/package.json | 2 +- packages/ssr/src/index.ts | 6 +- packages/ssr/test/ssr.test.ts | 13 ++ packages/styles/package.json | 2 +- packages/styles/src/fonts.ts | 30 +++ packages/styles/src/index.ts | 2 +- packages/styles/test/fonts.test.ts | 12 +- packages/test/package.json | 2 +- packages/tracking/package.json | 2 +- packages/ui/package.json | 2 +- packages/uploader/package.json | 2 +- packages/validation/package.json | 2 +- 68 files changed, 555 insertions(+), 107 deletions(-) create mode 100644 packages/cli/src/update-notifier.ts create mode 100644 packages/cli/test/update-notifier.test.ts create mode 100644 packages/cli/test/update.test.ts create mode 100644 packages/dev-server/test/performance-runtime.test.ts diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 348ab672..15530150 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index dc5ca75f..27bd449a 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/cli/README.md b/.publish/cli/README.md index 9aace6f0..7b3e7f59 100644 --- a/.publish/cli/README.md +++ b/.publish/cli/README.md @@ -76,6 +76,12 @@ bun dist/server.js # PORT env var optional Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts. +### `wrnexus update` + +`wrnexus update --latest` performs a complete project upgrade. It hands control to the exact target CLI, backs up important project files under `.wrnexus/update-backups/`, updates every `@wrnexus/*` dependency, refreshes framework-owned references, and applies every versioned syntax/config/file migration between the project version and target version. After installation it runs the project's `check` and `build` scripts; the new version is recorded only after verification succeeds. + +Use `--dry-run` to preview an upgrade or `--no-verify` when verification is intentionally handled elsewhere. Migrations never overwrite user-owned configuration wholesale: each release must provide a focused, idempotent transformation for any changed syntax or config contract. + ```bash wrnexus create my-app ``` diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 8d652603..61badbc9 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/cli — part of the WrNexus framework.", "license": "MIT", @@ -28,16 +28,16 @@ "wrnexus": "./dist/index.js" }, "dependencies": { - "@wrnexus/core": "^0.2.13", - "@wrnexus/router": "^0.2.13", - "@wrnexus/csr": "^0.2.13", - "@wrnexus/compiler": "^0.2.13", - "@wrnexus/styles": "^0.2.13", - "@wrnexus/dev-server": "^0.2.13", - "@wrnexus/ui": "^0.2.13", - "@wrnexus/validation": "^0.2.13", - "@wrnexus/i18n": "^0.2.13", - "@wrnexus/db": "^0.2.13" + "@wrnexus/core": "^0.2.14", + "@wrnexus/router": "^0.2.14", + "@wrnexus/csr": "^0.2.14", + "@wrnexus/compiler": "^0.2.14", + "@wrnexus/styles": "^0.2.14", + "@wrnexus/dev-server": "^0.2.14", + "@wrnexus/ui": "^0.2.14", + "@wrnexus/validation": "^0.2.14", + "@wrnexus/i18n": "^0.2.14", + "@wrnexus/db": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index 9076fa20..c5354cfa 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/core/package.json b/.publish/core/package.json index ac40812c..7cd5f055 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 24029fcc..450ecce4 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.13" + "@wrnexus/core": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index d7bf0a96..e455940e 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/db — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index 65f931ee..3772e2ef 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -25,18 +25,18 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.13", - "@wrnexus/router": "^0.2.13", - "@wrnexus/ssr": "^0.2.13", - "@wrnexus/csr": "^0.2.13", - "@wrnexus/compiler": "^0.2.13", - "@wrnexus/styles": "^0.2.13", - "@wrnexus/ui": "^0.2.13", - "@wrnexus/validation": "^0.2.13", - "@wrnexus/i18n": "^0.2.13", - "@wrnexus/db": "^0.2.13", - "@wrnexus/pubsub": "^0.2.13", - "@wrnexus/uploader": "^0.2.13" + "@wrnexus/core": "^0.2.14", + "@wrnexus/router": "^0.2.14", + "@wrnexus/ssr": "^0.2.14", + "@wrnexus/csr": "^0.2.14", + "@wrnexus/compiler": "^0.2.14", + "@wrnexus/styles": "^0.2.14", + "@wrnexus/ui": "^0.2.14", + "@wrnexus/validation": "^0.2.14", + "@wrnexus/i18n": "^0.2.14", + "@wrnexus/db": "^0.2.14", + "@wrnexus/pubsub": "^0.2.14", + "@wrnexus/uploader": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index 0603711e..86e6cf11 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/encryption — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index 6ce23fd0..67a9a7ef 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.13" + "@wrnexus/core": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index af9da557..86da75dc 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/jwt — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index cea3e338..fff308d9 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.2.13" + "@wrnexus/native": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index 04380e39..ba17d37b 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index 191ff49d..ce073bdc 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index c42a71ab..0ddca09f 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 6b87d9ba..e32ce67a 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index 31bc1adb..9a986470 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/router/package.json b/.publish/router/package.json index 7e3ee152..f7763ee9 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.2.13", - "@wrnexus/core": "^0.2.13" + "@wrnexus/compiler": "^0.2.14", + "@wrnexus/core": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 145596ee..ea21a688 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.13" + "@wrnexus/core": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index 828e29e9..40341002 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.2.13" + "@wrnexus/uploader": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/test/package.json b/.publish/test/package.json index 550cb13b..1c0db4a6 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index ba4961c8..c99ae5c9 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 4a73f848..c8da4044 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -22,7 +22,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.2.13" + "@wrnexus/core": "^0.2.14" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 0a139b6c..c92fd62f 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.13" + "@wrnexus/core": "^0.2.14" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index e3d52871..e77a4a94 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", diff --git a/packages/ai/package.json b/packages/ai/package.json index 6b941e7b..5c1c60b2 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", diff --git a/packages/authz/package.json b/packages/authz/package.json index 7a74e95a..12572de1 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/README.md b/packages/cli/README.md index 9aace6f0..7b3e7f59 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -76,6 +76,12 @@ bun dist/server.js # PORT env var optional Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts. +### `wrnexus update` + +`wrnexus update --latest` performs a complete project upgrade. It hands control to the exact target CLI, backs up important project files under `.wrnexus/update-backups/`, updates every `@wrnexus/*` dependency, refreshes framework-owned references, and applies every versioned syntax/config/file migration between the project version and target version. After installation it runs the project's `check` and `build` scripts; the new version is recorded only after verification succeeds. + +Use `--dry-run` to preview an upgrade or `--no-verify` when verification is intentionally handled elsewhere. Migrations never overwrite user-owned configuration wholesale: each release must provide a focused, idempotent transformation for any changed syntax or config contract. + ```bash wrnexus create my-app ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c70993d..3c67f013 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/ai-guide.ts b/packages/cli/src/ai-guide.ts index f50f1b92..341b046a 100644 --- a/packages/cli/src/ai-guide.ts +++ b/packages/cli/src/ai-guide.ts @@ -252,6 +252,7 @@ wrnexus dev . # dev server + HMR wrnexus build . # production build → dist/server.js bun dist/server.js # run the production server (or npm start) wrnexus create # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification wrnexus generate page # scaffold a page (aliases: g p) wrnexus generate component | api | schema wrnexus db migrate | rollback | status | new [--from-models] | generate | seed diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 269488b4..a4f31db8 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -19,7 +19,7 @@ import { compileWireFile } from "@wrnexus/compiler"; import { loadAppConfig, headToString, - renderFontHead, + renderProductionFontHead, findStyleEntry, renderStyles, resolveThemeConfig, @@ -124,8 +124,10 @@ export async function runBuild(appRoot: string): Promise { // 1a2) Wire UI stylesheet (all component classes, themed via tokens). const uiStyles = uiCss(); writeFileSync(join(distDir, "ui.css"), uiStyles, "utf8"); + const frameworkStyles = `${themeCss}\n${uiStyles}`; + writeFileSync(join(distDir, "framework.css"), frameworkStyles, "utf8"); assetHash.update(uiStyles); - console.log(`✓ UI: dist/ui.css`); + console.log(`✓ UI: dist/ui.css + framework.css`); // 1a3) Validation: bake schema descriptors into the client script. const descriptors: Record = {}; @@ -164,7 +166,7 @@ export async function runBuild(appRoot: string): Promise { console.log(`✓ Styles: ${join(distDir, "styles.css")}`); } const assetVersion = assetHash.digest("hex").slice(0, 12); - const headStr = [renderFontHead(config.fonts), headToString(config.head)] + const headStr = [await renderProductionFontHead(config.fonts), headToString(config.head)] .filter(Boolean) .join("\n "); @@ -232,6 +234,7 @@ await createProductionServer( themeJsPath: join(import.meta.dir, "theme.js"), theme: ${JSON.stringify(theme)}, uiCssPath: join(import.meta.dir, "ui.css"), + frameworkCssPath: join(import.meta.dir, "framework.css"), schemasJs: ${JSON.stringify(schemasJs)}, i18n: ${i18n ? JSON.stringify(i18n) : "undefined"}, db: ${config.db ? JSON.stringify(config.db) : "undefined"}, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4680a462..34e369d0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import { join, resolve } from "node:path"; import { resolveProfile, loadEnv } from "@wrnexus/styles"; import { runDev } from "./dev.ts"; import { createApp } from "./create.ts"; +import { currentCliVersion, notifyIfUpdateAvailable } from "./update-notifier.ts"; /** * Resolve the active profile from `--profile=` (or WRNEXUS_PROFILE / mode), @@ -51,7 +52,7 @@ Usage: wrnexus native list List cross-platform native capabilities wrnexus native add Install capability packages for the configured mobile mode wrnexus eject Copy a Wire UI component into app/components - wrnexus update [dir] [--latest] Upgrade @wrnexus/* deps + apply config/file migrations + wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app wrnexus db Migrations: migrate | rollback | status | seed | generate | new wrnexus test [app-dir] [--watch] Run the app's tests (bun test, 'test' profile) wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files @@ -59,12 +60,16 @@ Usage: Profiles: pass --profile= to dev/build/db (or set WRNEXUS_PROFILE) to load that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat + +Update options: --dry-run previews changes; --no-verify skips post-update check/build. `); } async function main(): Promise { const [command, ...rest] = process.argv.slice(2); + notifyIfUpdateAvailable(currentCliVersion(), command); + switch (command) { case "dev": { const appRoot = rest.find((a) => !a.startsWith("--")) ?? "."; diff --git a/packages/cli/src/update-notifier.ts b/packages/cli/src/update-notifier.ts new file mode 100644 index 00000000..66ffd273 --- /dev/null +++ b/packages/cli/src/update-notifier.ts @@ -0,0 +1,83 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +interface UpdateCache { + checkedAt: number; + latest: string; +} + +export function currentCliVersion(): string { + try { + return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version; + } catch { + return "0.0.0"; + } +} + +export function compareVersions(a: string, b: string): number { + const left = a.split("-")[0]!.split(".").map(Number); + const right = b.split("-")[0]!.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const difference = (left[i] || 0) - (right[i] || 0); + if (difference) return difference > 0 ? 1 : -1; + } + return 0; +} + +function cachePath(): string { + return process.env.WRNEXUS_UPDATE_CACHE ?? join(homedir(), ".wrnexus", "update-check.json"); +} + +function cachedLatest(now: number): string | null { + try { + const cached = JSON.parse(readFileSync(cachePath(), "utf8")) as UpdateCache; + return now - cached.checkedAt < CHECK_INTERVAL_MS ? cached.latest : null; + } catch { + return null; + } +} + +function registryLatest(): string | null { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(npm, ["view", "@wrnexus/cli", "version"], { + encoding: "utf8", + timeout: 5000, + windowsHide: true, + }); + const version = (result.stdout ?? "").trim(); + return result.status === 0 && /^\d+\.\d+\.\d+/.test(version) ? version : null; +} + +/** Check at most once per day and print a non-blocking upgrade suggestion. */ +export function notifyIfUpdateAvailable(current: string, command?: string): void { + if ( + process.env.WRNEXUS_NO_UPDATE_CHECK === "1" || + command === "update" || + command === "upgrade" + ) { + return; + } + const now = Date.now(); + let latest = cachedLatest(now); + if (!latest) { + latest = registryLatest(); + if (!latest) return; + try { + const file = cachePath(); + if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify({ checkedAt: now, latest }) + "\n", "utf8"); + } catch { + // A read-only home directory must never break the CLI. + } + } + if (compareVersions(latest, current) > 0) { + console.log( + `\n Update available: WRNexusJS ${current} → ${latest}\n` + + ` Run: wrnexus update --latest\n`, + ); + } +} diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 3992edf3..16aa37fe 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -17,11 +17,12 @@ * as the framework evolves — that is how "new things" reach existing apps. */ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts"; +import { inspectProject } from "./doctor.ts"; /** The version of the CLI currently running (its own package.json). */ function cliVersion(): string { @@ -35,7 +36,8 @@ function cliVersion(): string { /** Query the registry for the latest published `@wrnexus/cli` version. */ function latestPublished(): string | null { try { - const out = spawnSync("npm", ["view", "@wrnexus/cli", "version"], { encoding: "utf8" }); + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const out = spawnSync(npm, ["view", "@wrnexus/cli", "version"], { encoding: "utf8" }); const v = (out.stdout ?? "").trim(); return /^\d+\.\d+\.\d+/.test(v) ? v : null; } catch { @@ -92,6 +94,68 @@ const MIGRATIONS: Migration[] = [ writeFileSync(file, next.replace(/^\n+/, ""), "utf8"); }, }, + { + version: "0.2.14", + id: "complete-gitignore", + description: "Add the complete WRNexusJS generated-project ignore set", + apply(ctx) { + const file = join(ctx.appRoot, ".gitignore"); + const want = [ + "node_modules/", + "dist/", + ".wrnexus/", + "**/.wrnexus/", + "coverage/", + ".env", + ".env.*", + "!.env.example", + "*.log", + "*.db", + "*.db-shm", + "*.db-wal", + "*.sqlite", + "*.sqlite3", + "mobile/android/", + "mobile/ios/", + "mobile/.expo/", + ".idea/", + ".vscode/", + ".DS_Store", + "Thumbs.db", + "*.tsbuildinfo", + ".eslintcache", + ]; + const current = existsSync(file) ? readFileSync(file, "utf8") : ""; + const have = new Set(current.split(/\r?\n/).map((line) => line.trim())); + const missing = want.filter((entry) => !have.has(entry)); + if (!missing.length) return; + ctx.log(`+ .gitignore: ${missing.join(", ")}`); + if (!ctx.dryRun) { + const next = `${current.replace(/\s*$/, "")}\n${missing.join("\n")}\n`.replace(/^\n+/, ""); + writeFileSync(file, next, "utf8"); + } + }, + }, + { + version: "0.2.14", + id: "production-scripts", + description: "Add standard production build and start scripts", + apply(ctx) { + // Workspace roots are manifests, not runnable applications. + if (!existsSync(join(ctx.appRoot, "app", "pages"))) return; + const file = join(ctx.appRoot, "package.json"); + const pkg = JSON.parse(readFileSync(file, "utf8")) as Record; + const scripts = (pkg.scripts ??= {}); + const additions: Record = {}; + if (!scripts.build) additions.build = "wrnexus build ."; + if (!scripts.start) additions.start = "bun dist/server.js"; + if (!scripts.production) additions.production = "bun run build && bun run start"; + if (!Object.keys(additions).length) return; + Object.assign(scripts, additions); + ctx.log(`+ package scripts: ${Object.keys(additions).join(", ")}`); + if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8"); + }, + }, ]; /** Bump every `@wrnexus/*` range to `^target`. Returns the human-readable changes. */ @@ -141,17 +205,46 @@ function refreshFrameworkFiles(appRoot: string, dryRun: boolean, log: (m: string } /** Update a single app dir (bump its package.json, refresh files, run migrations). */ -function updateApp(appRoot: string, target: string, dryRun: boolean): boolean { +interface UpdatedApp { + root: string; + packagePath: string; + pkg: Record; +} + +function backupProjectFiles(appRoot: string, from: string, target: string): string { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = join(appRoot, ".wrnexus", "update-backups", `${stamp}-${from}-to-${target}`); + mkdirSync(backup, { recursive: true }); + for (const name of [ + "package.json", + "wrnexus.config.ts", + "wrnexus.config.js", + "wrnexus.config.mjs", + "tsconfig.json", + ".gitignore", + "CLAUDE.md", + "llms.txt", + ]) { + const source = join(appRoot, name); + if (existsSync(source)) cpSync(source, join(backup, name)); + } + return backup; +} + +/** Update one app without marking success until install and verification pass. */ +export function updateApp(appRoot: string, target: string, dryRun: boolean): UpdatedApp | null { const pkgPath = join(appRoot, "package.json"); if (!existsSync(pkgPath)) { console.log(` ⚠ ${appRoot}: no package.json — skipped`); - return false; + return null; } const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record; const from = appVersion(appRoot, pkg); const log = (m: string) => console.log(` ${m}`); console.log(` ▸ ${pkg.name ?? appRoot} (${from} → ${target})`); + if (!dryRun) log(`backup: ${backupProjectFiles(appRoot, from, target)}`); + const changes = bumpDeps(pkg, target); changes.forEach((c) => log(c)); if (!changes.length) log("dependencies already current"); @@ -164,9 +257,32 @@ function updateApp(appRoot: string, target: string, dryRun: boolean): boolean { } } - // Record the applied version so the next update knows where it started. - pkg.wrnexus = { ...(pkg.wrnexus as object), version: target }; + // A migration may have conservatively edited package.json. Merge those + // changes back before writing the dependency bump so neither side is lost. + if (!dryRun) { + Object.assign(pkg, JSON.parse(readFileSync(pkgPath, "utf8")) as Record); + bumpDeps(pkg, target); + } if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8"); + return { root: appRoot, packagePath: pkgPath, pkg }; +} + +function verifyApp(app: UpdatedApp): boolean { + const health = inspectProject(app.root); + const failed = health.filter((check) => !check.ok && check.name !== "configuration"); + if (failed.length) { + for (const check of failed) console.error(` ✗ ${check.name}: ${check.detail}`); + return false; + } + const scripts = (app.pkg.scripts ?? {}) as Record; + const commands: string[][] = []; + if (scripts.check) commands.push(["run", "check"]); + if (scripts.build) commands.push(["run", "build"]); + for (const args of commands) { + console.log(`\n Verifying ${app.pkg.name ?? app.root}: bun ${args.join(" ")}…`); + const result = spawnSync(process.execPath, args, { cwd: app.root, stdio: "inherit" }); + if (result.status !== 0) return false; + } return true; } @@ -186,17 +302,42 @@ async function workspaceApps(root: string): Promise { export async function runUpdate(dir: string, args: string[]): Promise { const root = resolve(dir); const dryRun = args.includes("--dry-run"); + const verify = !args.includes("--no-verify"); const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1]; const target = versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion(); + // Always let the target CLI apply its own migrations. Without this handoff, + // an older installed CLI could bump dependency versions but would not know + // about syntax/config migrations shipped by the newer release. + if (!args.includes("--delegated") && cmp(target, cliVersion()) > 0) { + console.log(`\n Fetching WRNexusJS CLI ${target} to run its project migrations…`); + const forwarded = args.filter( + (arg) => !arg.startsWith("--version=") && arg !== "--latest" && arg !== "--delegated", + ); + const result = spawnSync( + process.execPath, + [ + "x", + `@wrnexus/cli@${target}`, + "update", + dir, + `--version=${target}`, + "--delegated", + ...forwarded, + ], + { cwd: root, stdio: "inherit" }, + ); + if (result.status !== 0) process.exitCode = result.status ?? 1; + return; + } + console.log(`\n ⚡ wrnexus update → ${target}${dryRun ? " (dry run)" : ""}\n`); // Workspace → update the root manifest + every app; else just this app. const apps = await workspaceApps(root); const targets = apps ? [root, ...apps] : [root]; - let updated = 0; - for (const t of targets) if (updateApp(t, target, dryRun)) updated++; + const updated = targets.map((t) => updateApp(t, target, dryRun)).filter(Boolean) as UpdatedApp[]; if (dryRun) { console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`); @@ -211,6 +352,24 @@ export async function runUpdate(dir: string, args: string[]): Promise { process.exit(res.status ?? 1); } - console.log(`\n ✓ Updated ${updated} package(s) to ${target}.`); - console.log(` Review the changes, then rebuild/redeploy (wrnexus build).\n`); + if (verify) { + const appsToVerify = apps ? updated.filter((app) => app.root !== root) : updated; + for (const app of appsToVerify) { + if (!verifyApp(app)) { + console.error( + `\n ✗ Update files were applied, but verification failed. Fix the reported error and re-run wrnexus update.`, + ); + process.exitCode = 1; + return; + } + } + } + + for (const app of updated) { + app.pkg.wrnexus = { ...(app.pkg.wrnexus as object), version: target }; + writeFileSync(app.packagePath, JSON.stringify(app.pkg, null, 2) + "\n", "utf8"); + } + + console.log(`\n ✓ Updated and verified ${updated.length} package(s) at ${target}.`); + console.log(` Backups are under .wrnexus/update-backups/. Review and redeploy.\n`); } diff --git a/packages/cli/test/update-notifier.test.ts b/packages/cli/test/update-notifier.test.ts new file mode 100644 index 00000000..7c3a9def --- /dev/null +++ b/packages/cli/test/update-notifier.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compareVersions, notifyIfUpdateAvailable } from "../src/update-notifier.ts"; + +const originalCache = process.env.WRNEXUS_UPDATE_CACHE; + +afterEach(() => { + if (originalCache === undefined) delete process.env.WRNEXUS_UPDATE_CACHE; + else process.env.WRNEXUS_UPDATE_CACHE = originalCache; +}); + +test("compareVersions compares stable semantic versions", () => { + expect(compareVersions("0.2.14", "0.2.13")).toBe(1); + expect(compareVersions("0.2.13", "0.2.13")).toBe(0); + expect(compareVersions("0.2.12", "0.2.13")).toBe(-1); +}); + +test("cached update checks suggest the exact upgrade command", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-notify-")); + const cache = join(root, "update.json"); + process.env.WRNEXUS_UPDATE_CACHE = cache; + writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), latest: "0.2.14" })); + const messages: string[] = []; + const original = console.log; + console.log = (...parts) => messages.push(parts.join(" ")); + + try { + notifyIfUpdateAvailable("0.2.13", "dev"); + expect(messages.join("\n")).toContain("WRNexusJS 0.2.13 → 0.2.14"); + expect(messages.join("\n")).toContain("wrnexus update --latest"); + } finally { + console.log = original; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/update.test.ts b/packages/cli/test/update.test.ts new file mode 100644 index 00000000..861562c1 --- /dev/null +++ b/packages/cli/test/update.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { updateApp } from "../src/update.ts"; + +test("updateApp migrates project files without marking an unverified update complete", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-update-")); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "legacy-app", + scripts: { dev: "wrnexus dev ." }, + dependencies: { "@wrnexus/core": "0.2.13" }, + wrnexus: { version: "0.2.13" }, + }), + ); + writeFileSync(join(root, ".gitignore"), "node_modules/\n"); + + try { + const result = updateApp(root, "0.2.14", false); + expect(result).not.toBeNull(); + const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + expect(pkg.dependencies["@wrnexus/core"]).toBe("^0.2.14"); + expect(pkg.scripts.build).toBe("wrnexus build ."); + expect(pkg.scripts.start).toBe("bun dist/server.js"); + expect(pkg.scripts.production).toBe("bun run build && bun run start"); + expect(pkg.wrnexus.version).toBe("0.2.13"); + + const gitignore = readFileSync(join(root, ".gitignore"), "utf8"); + expect(gitignore).toContain("!.env.example"); + expect(gitignore).toContain("mobile/android/"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index e6d0ab63..91fd875c 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index 2532d384..950dde19 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index e1a2e26f..d3e41923 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -67,6 +67,8 @@ export type Middleware = (ctx: Context, next: Next) => Promise | Respo /** SEO metadata rendered into the document ``. */ export type SeoConfig = { + /** BCP 47 document language used on `` (default: `en`). */ + lang?: string; title?: string; titleTemplate?: string; description?: string; diff --git a/packages/csr/package.json b/packages/csr/package.json index d167726a..654fdd5d 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index b4b63b6b..194a479e 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 8fb15d09..f4cb4171 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 07fa67e4..732db494 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -75,6 +75,8 @@ export interface ProdOptions { theme?: ResolvedTheme; /** Absolute path to the pre-built Wire UI stylesheet (`ui.css`). */ uiCssPath?: string; + /** Combined production theme + Wire UI stylesheet. */ + frameworkCssPath?: string; /** Pre-built `window.__wireSchemas = {...}` script for client validation. */ schemasJs?: string; /** Resolved i18n bundle (default lang + locale messages). */ @@ -216,6 +218,8 @@ function createProdAssetServer(opts: ProdOptions): AssetServer { if (pathname === "/__wrnexus/theme.css") return serveFile(opts.themePath, CSS_HEADERS); if (pathname === "/__wrnexus/theme.js") return serveFile(opts.themeJsPath, JS_HEADERS); if (pathname === "/__wrnexus/ui.css") return serveFile(opts.uiCssPath, CSS_HEADERS); + if (pathname === "/__wrnexus/framework.css") + return serveFile(opts.frameworkCssPath, CSS_HEADERS); if (pathname === "/__wrnexus/styles.css") return serveFile(opts.stylesPath, CSS_HEADERS); return servePublicAsset(opts.publicDir, pathname, MODE); }, @@ -279,6 +283,7 @@ export function createProductionHandlers( assets, hasStyles: !!opts.stylesPath, hasUi: !!opts.uiCssPath, + hasFrameworkStyles: !!opts.frameworkCssPath, theme: opts.theme, i18n: opts.i18n, inlineStyles: opts.inlineStyles, diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 53776f04..8ac86c52 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -101,6 +101,8 @@ export interface RuntimeDeps { hasStyles?: boolean; /** When true, inject the Wire UI stylesheet link (`/__wrnexus/ui.css`). */ hasUi?: boolean; + /** Production build combined theme + UI stylesheet. */ + hasFrameworkStyles?: boolean; /** Resolved theme config: enables `/__wrnexus/theme.css` + ``. */ theme?: ResolvedTheme; /** Resolved i18n bundle: enables `ctx.t`, ``, and `{t:key}` markers. */ @@ -129,8 +131,14 @@ export interface RuntimeDeps { realtimeBus?: RealtimeBus; } -const PWA_CLIENT = `if ("serviceWorker" in navigator) { - addEventListener("load", () => navigator.serviceWorker.register("/sw.js").catch(() => {})); +export const PWA_CLIENT = `if ("serviceWorker" in navigator) { + var swUrl = "/sw.js"; + if (window.trustedTypes) { + try { + swUrl = window.trustedTypes.createPolicy("wrnexus-pwa", { createScriptURL: function(value) { return value; } }).createScriptURL(swUrl); + } catch (_) {} + } + addEventListener("load", () => navigator.serviceWorker.register(swUrl).catch(() => {})); }`; function renderPwaServiceWorker(pwa: PwaConfig): string { @@ -561,11 +569,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers { // Order: theme tokens, then Wire UI, then the app stylesheet — so the app's // own CSS (loaded last) can override both the tokens and the UI classes. const headParts: string[] = []; - if (deps.theme) { + if (deps.hasFrameworkStyles) { + headParts.push( + ``, + ); + } else if (deps.theme) { const themeHref = versionAssetUrl(THEME_CSS_HREF, deps.assetVersion); headParts.push(``); } - if (deps.hasUi) { + if (deps.hasUi && !deps.hasFrameworkStyles) { headParts.push( ``, ); @@ -677,7 +689,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers { new Response(PWA_CLIENT, { headers: { "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-cache", + "cache-control": url.searchParams.has("v") + ? "public, max-age=31536000, immutable" + : "no-cache", }, }), ); @@ -687,7 +701,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers { new Response(MOBILE_CLIENT, { headers: { "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-cache", + "cache-control": url.searchParams.has("v") + ? "public, max-age=31536000, immutable" + : "no-cache", }, }), ); @@ -1046,14 +1062,17 @@ export function createHandlers(deps: RuntimeDeps): Handlers { // Point 3: only ship the JS this page actually uses. const scripts = collectScripts(body).map((src) => versionAssetUrl(src, deps.assetVersion)); - if (pwaServiceWorkerEnabled) scripts.push("/__wrnexus/pwa.js"); - if (deps.mobile?.enabled !== false) scripts.push("/__wrnexus/mobile.js"); + if (pwaServiceWorkerEnabled) + scripts.push(versionAssetUrl("/__wrnexus/pwa.js", deps.assetVersion)); + if (deps.mobile?.enabled !== false && usesMobileRuntime(body)) + scripts.push(versionAssetUrl("/__wrnexus/mobile.js", deps.assetVersion)); // attributes: no-flash theme (from cookie, validated) + active lang. const attrs: string[] = []; if (deps.theme) attrs.push(`data-theme="${resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme)}"`); - if (deps.i18n && ctx.lang) attrs.push(`lang="${ctx.lang}"`); + const language = deps.i18n && ctx.lang ? ctx.lang : (meta.lang ?? deps.seo?.lang ?? "en"); + attrs.push(`lang="${safeLanguageTag(language)}"`); const htmlAttrs = attrs.length ? ` ${attrs.join(" ")}` : undefined; const html = renderDocument({ @@ -1412,6 +1431,17 @@ export function collectScripts(body: string): string[] { return scripts; } +/** Whether rendered markup needs the Capacitor/native browser bridge. */ +export function usesMobileRuntime(body: string): boolean { + return /\b(?:data-mobile-[\w-]+|data-native-(?:browser|mobile|only|requires|unsupported|options)|data-on-wrnexus-(?:browser|mobile)-[\w-]+)\b/.test( + body, + ); +} + +function safeLanguageTag(value: string): string { + return /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value) ? value : "en"; +} + function versionAssetUrl(src: string, version?: string): string { if (!version) return src; return `${src}${src.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`; diff --git a/packages/dev-server/test/performance-runtime.test.ts b/packages/dev-server/test/performance-runtime.test.ts new file mode 100644 index 00000000..22a94419 --- /dev/null +++ b/packages/dev-server/test/performance-runtime.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { PWA_CLIENT, usesMobileRuntime } from "../src/runtime.ts"; + +test("PWA registration is valid JavaScript and Trusted Types compatible", () => { + expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(PWA_CLIENT)).not.toThrow(); + expect(PWA_CLIENT).toContain('createPolicy("wrnexus-pwa"'); + expect(PWA_CLIENT).toContain("createScriptURL(swUrl)"); +}); + +test("mobile runtime is shipped only for pages using mobile or native directives", () => { + expect(usesMobileRuntime('
Docs
')).toBe(false); + expect(usesMobileRuntime('')).toBe(true); + expect(usesMobileRuntime("
App
")).toBe(true); + expect(usesMobileRuntime('')).toBe( + true, + ); +}); diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 6b46e3d1..56edb135 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 4c5343a0..5914a1ca 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index f9d8f03a..28ed9343 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index dc5f464d..1d1efc47 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 87f0b51b..15cce6bd 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index fe72f137..d1f658c5 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 3fe953ac..4d70f6c1 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 13e763ce..fe3ea194 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index df58f5e8..c219287e 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index 90ab8a28..97d63b5f 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 359bf74d..db7c82f7 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/src/index.ts b/packages/ssr/src/index.ts index 6d200ee7..5509e515 100644 --- a/packages/ssr/src/index.ts +++ b/packages/ssr/src/index.ts @@ -55,8 +55,12 @@ export function renderDocument(opts: RenderOptions): string { const extraHead = opts.extraHead ? `\n ${opts.extraHead}` : ""; const extraBody = opts.extraBody ? `\n ${opts.extraBody}` : ""; + const htmlAttrs = /(?:^|\s)lang\s*=/.test(opts.htmlAttrs ?? "") + ? (opts.htmlAttrs ?? "") + : `${opts.htmlAttrs ?? ""} lang="en"`; + return ` - + diff --git a/packages/ssr/test/ssr.test.ts b/packages/ssr/test/ssr.test.ts index f1c5a169..a70919c5 100644 --- a/packages/ssr/test/ssr.test.ts +++ b/packages/ssr/test/ssr.test.ts @@ -22,3 +22,16 @@ test("deduplicates runtime scripts and module preloads", () => { expect(html.match(/rel="modulepreload"/g)).toHaveLength(1); expect(html.match(/src="\/app\.js"/g)).toHaveLength(1); }); + +test("always emits a document language and preserves an explicit language", () => { + const fallback = renderDocument({ meta: {}, body: "" }); + expect(fallback).toContain(''); + + const explicit = renderDocument({ + meta: {}, + body: "", + htmlAttrs: ' data-theme="dark" lang="fr"', + }); + expect(explicit).toContain(''); + expect(explicit).not.toContain('lang="en"'); +}); diff --git a/packages/styles/package.json b/packages/styles/package.json index e230f693..d53232f8 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/fonts.ts b/packages/styles/src/fonts.ts index 9fa6c05a..bf5e7d4f 100644 --- a/packages/styles/src/fonts.ts +++ b/packages/styles/src/fonts.ts @@ -175,6 +175,36 @@ export function renderFontHead(fonts?: FontConfig): string { return out.join("\n "); } +/** + * Production variant that inlines the small Google Fonts stylesheet at build + * time. This removes a render-blocking CSS round trip while retaining the same + * font files, `font-display`, CSP sources, and offline-safe fallback markup. + */ +export async function renderProductionFontHead( + fonts?: FontConfig, + fetcher: (input: string, init?: RequestInit) => Promise = fetch, +): Promise { + const fallback = renderFontHead(fonts); + if (!fonts?.google?.length) return fallback; + const url = googleFontsUrl(fonts.google, fonts.display ?? "swap"); + try { + const response = await fetcher(url, { + headers: { + // Google returns compact WOFF2 rules to modern browser user agents. + "user-agent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36", + }, + }); + if (!response.ok) return fallback; + const css = (await response.text()).replace(/<\/style/gi, "<\\/style"); + const stylesheet = ``; + return fallback + .replace(`\n `, "") + .replace(stylesheet, ``); + } catch { + return fallback; + } +} + /** * CSP source hosts required by the configured fonts, so the policy can be * auto-extended (Google Fonts need their CSS + static hosts allow-listed). diff --git a/packages/styles/src/index.ts b/packages/styles/src/index.ts index bdfa7a6e..be9e6658 100644 --- a/packages/styles/src/index.ts +++ b/packages/styles/src/index.ts @@ -18,7 +18,7 @@ export type { export { loadAppConfig, loadRawConfig, headToString, resolveProfile, loadEnv } from "./config.ts"; export { findStyleEntry, bundleCss } from "./styles.ts"; export type { FontConfig, GoogleFont, LocalFontFace, FontDisplay } from "./fonts.ts"; -export { renderFontHead, fontCspSources } from "./fonts.ts"; +export { renderFontHead, renderProductionFontHead, fontCspSources } from "./fonts.ts"; export type { ThemeConfig, ThemeTokens, ResolvedTheme } from "./theme.ts"; export { DEFAULT_THEMES, diff --git a/packages/styles/test/fonts.test.ts b/packages/styles/test/fonts.test.ts index 6fe1a0a9..2023ee7d 100644 --- a/packages/styles/test/fonts.test.ts +++ b/packages/styles/test/fonts.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { fontCspSources, renderFontHead } from "../src/index.ts"; +import { fontCspSources, renderFontHead, renderProductionFontHead } from "../src/index.ts"; test("renders subsetted Google and escaped local font markup", () => { const html = renderFontHead({ @@ -20,3 +20,13 @@ test("renders subsetted Google and escaped local font markup", () => { font: ["https://fonts.gstatic.com"], }); }); + +test("production font head inlines Google CSS and removes its blocking stylesheet", async () => { + const html = await renderProductionFontHead( + { google: [{ family: "Plus Jakarta Sans", weights: [400, 700] }] }, + () => Promise.resolve(new Response('@font-face{font-family:"Plus Jakarta Sans"}')), + ); + expect(html).toContain("data-wrnexus-google-fonts"); + expect(html).toContain("@font-face"); + expect(html).not.toContain('