diff --git a/.publish/ai/package.json b/.publish/ai/package.json index a5caa239..5522a6ee 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.74", + "version": "0.2.75", "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 6542d886..03cf1d3e 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 6c7304da..b49e5ca3 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.74", + "version": "0.2.75", "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.74", - "@wrnexus/router": "^0.2.74", - "@wrnexus/csr": "^0.2.74", - "@wrnexus/compiler": "^0.2.74", - "@wrnexus/styles": "^0.2.74", - "@wrnexus/dev-server": "^0.2.74", - "@wrnexus/ui": "^0.2.74", - "@wrnexus/validation": "^0.2.74", - "@wrnexus/i18n": "^0.2.74", - "@wrnexus/db": "^0.2.74" + "@wrnexus/core": "^0.2.75", + "@wrnexus/router": "^0.2.75", + "@wrnexus/csr": "^0.2.75", + "@wrnexus/compiler": "^0.2.75", + "@wrnexus/styles": "^0.2.75", + "@wrnexus/dev-server": "^0.2.75", + "@wrnexus/ui": "^0.2.75", + "@wrnexus/validation": "^0.2.75", + "@wrnexus/i18n": "^0.2.75", + "@wrnexus/db": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index d6a0b523..3adfffcc 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.74", + "version": "0.2.75", "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 96c07cce..7511deb1 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.74", + "version": "0.2.75", "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 8e2fe2d4..54f8f0f5 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.74" + "@wrnexus/core": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index be37188e..80e398be 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.74", + "version": "0.2.75", "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 6622cfd7..50f702cf 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -25,18 +25,19 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.74", - "@wrnexus/router": "^0.2.74", - "@wrnexus/ssr": "^0.2.74", - "@wrnexus/csr": "^0.2.74", - "@wrnexus/compiler": "^0.2.74", - "@wrnexus/styles": "^0.2.74", - "@wrnexus/ui": "^0.2.74", - "@wrnexus/validation": "^0.2.74", - "@wrnexus/i18n": "^0.2.74", - "@wrnexus/db": "^0.2.74", - "@wrnexus/pubsub": "^0.2.74", - "@wrnexus/uploader": "^0.2.74" + "@wrnexus/core": "^0.2.75", + "@wrnexus/dev-toolbar": "^0.2.75", + "@wrnexus/router": "^0.2.75", + "@wrnexus/ssr": "^0.2.75", + "@wrnexus/csr": "^0.2.75", + "@wrnexus/compiler": "^0.2.75", + "@wrnexus/styles": "^0.2.75", + "@wrnexus/ui": "^0.2.75", + "@wrnexus/validation": "^0.2.75", + "@wrnexus/i18n": "^0.2.75", + "@wrnexus/db": "^0.2.75", + "@wrnexus/pubsub": "^0.2.75", + "@wrnexus/uploader": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/dev-toolbar/README.md b/.publish/dev-toolbar/README.md new file mode 100644 index 00000000..7b07b04c --- /dev/null +++ b/.publish/dev-toolbar/README.md @@ -0,0 +1,23 @@ +# @wrnexus/dev-toolbar + +Development-only page quality toolbar for WRNexusJS. + +## Features + +- Runtime, resource and unhandled promise error capture +- Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks +- Performance and network observations +- Element highlighting and issue filtering +- Server-side issue collector +- Development-only asset strings for direct serving by `@wrnexus/dev-server` +- Safe open-in-editor helper + +## Dev-server integration + +Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS` at `/__wrnexus/dev-toolbar.css`, then inject this before `` in development: + +```html + +``` + +The browser runtime exposes `window.__wrnexusDevToolbar`. diff --git a/.publish/dev-toolbar/package.json b/.publish/dev-toolbar/package.json new file mode 100644 index 00000000..a833a85e --- /dev/null +++ b/.publish/dev-toolbar/package.json @@ -0,0 +1,42 @@ +{ + "name": "@wrnexus/dev-toolbar", + "version": "0.2.75", + "type": "module", + "description": "@wrnexus/dev-toolbar — part of the WrNexus framework.", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "engines": { + "bun": ">=1.1.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "restricted" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" + }, + "./rules": { + "types": "./dist/rules/index.d.ts", + "import": "./dist/rules/index.js" + } + }, + "files": [ + "dist" + ] +} diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index 88e85830..f51254a8 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/encryption — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 180aba57..56ae3e65 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.74" + "@wrnexus/core": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index 9e01ddd3..129d1eac 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.74" + "@wrnexus/core": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index 780c1b25..4a1eefae 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.74", + "version": "0.2.75", "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 1bcaba81..e3fa91cc 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.2.74" + "@wrnexus/native": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index e94d0710..f7d962b5 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.74", + "version": "0.2.75", "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 8c586ef3..f533c9ac 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.74", + "version": "0.2.75", "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 68400dfa..0fe19a07 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.74", + "version": "0.2.75", "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 8a3baade..e050a6d6 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.74", + "version": "0.2.75", "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 e082998f..efb5fa6d 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.74", + "version": "0.2.75", "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 460327d1..5a63010c 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.2.74", - "@wrnexus/core": "^0.2.74" + "@wrnexus/compiler": "^0.2.75", + "@wrnexus/core": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 23bd1d89..337861bb 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.74" + "@wrnexus/core": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index cc8220d4..ea57bbec 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.2.74" + "@wrnexus/uploader": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/test/package.json b/.publish/test/package.json index 230145e6..670afc34 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.74", + "version": "0.2.75", "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 1c8ee93f..08216048 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.74", + "version": "0.2.75", "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 fce13e49..74cc2864 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -25,7 +25,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.2.74" + "@wrnexus/core": "^0.2.75" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 6026faf8..7f1817f7 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.74" + "@wrnexus/core": "^0.2.75" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 6e31a1a0..75391ddd 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", diff --git a/CHANGED-FILES.txt b/CHANGED-FILES.txt new file mode 100644 index 00000000..c42421cf --- /dev/null +++ b/CHANGED-FILES.txt @@ -0,0 +1,9 @@ +tsconfig.json +packages/styles/src/config.ts +packages/dev-server/package.json +packages/dev-server/src/hmr.ts +packages/dev-server/src/watch.ts +packages/dev-server/src/runtime.ts +packages/dev-server/src/index.ts +packages/dev-server/src/serve-entry.ts +packages/dev-toolbar/src/client/runtime.ts diff --git a/REPLACE-FILES.md b/REPLACE-FILES.md new file mode 100644 index 00000000..a85fa269 --- /dev/null +++ b/REPLACE-FILES.md @@ -0,0 +1,81 @@ +# WRNexusJS DevToolbar Integration + +Extract this ZIP directly over the WRNexusJS repository root and allow these files to be replaced. + +## Replace files one by one + +1. `tsconfig.json` + - Adds workspace path aliases for `@wrnexus/dev-toolbar` exports. + +2. `packages/styles/src/config.ts` + - Adds `DevToolbarConfig` and `devToolbar` to `AppConfig`. + +3. `packages/dev-server/package.json` + - Confirms the workspace dependency on `@wrnexus/dev-toolbar`. + +4. `packages/dev-server/src/hmr.ts` + - Adds generic JSON broadcasting for toolbar events. + +5. `packages/dev-server/src/watch.ts` + - Clears stale source issues and triggers rescans for CSS changes. + +6. `packages/dev-server/src/runtime.ts` + - Serves toolbar routes. + - Injects the toolbar only in development HTML. + - Collects route, component, layout, and request errors. + - Sends HMR/browser events used by the toolbar. + +7. `packages/dev-server/src/index.ts` + - Resolves toolbar configuration. + - Creates the server issue collector. + - Connects collector updates to HMR. + - Passes the toolbar into runtime and watcher dependencies. + +8. `packages/dev-server/src/serve-entry.ts` + - Passes `config.devToolbar` from `wrnexus.config.ts` into the server. + +9. `packages/dev-toolbar/src/client/runtime.ts` + - Loads server-side issues. + - Receives live toolbar issue/scan messages. + - Rescans after HMR and client navigation. + +## Configuration + +The toolbar is enabled automatically in development. Optional configuration: + +```ts +export default { + devToolbar: { + enabled: true, + position: "bottom-center", + defaultOpen: false, + scanOnNavigation: true, + scanOnHmr: true, + openEditor: true, + editor: "code", + }, +}; +``` + +Disable it for an app: + +```ts +export default { + devToolbar: false, +}; +``` + +## After replacement + +Run from the repository root: + +```bash +bun install +bun run format +bun run typecheck +bun run lint +bun test +bun run check +``` + +The toolbar is development-only and is not injected when the server mode is `production`. diff --git a/bun.lock b/bun.lock index 07149327..07d022ed 100644 --- a/bun.lock +++ b/bun.lock @@ -35,15 +35,15 @@ }, "packages/ai": { "name": "@wrnexus/ai", - "version": "0.2.17", + "version": "0.2.74", }, "packages/authz": { "name": "@wrnexus/authz", - "version": "0.2.17", + "version": "0.2.74", }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.2.17", + "version": "0.2.74", "bin": { "wrnexus": "src/index.ts", }, @@ -62,31 +62,32 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.2.17", + "version": "0.2.74", }, "packages/core": { "name": "@wrnexus/core", - "version": "0.2.17", + "version": "0.2.74", }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/db": { "name": "@wrnexus/db", - "version": "0.2.17", + "version": "0.2.74", }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/compiler": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/csr": "workspace:*", "@wrnexus/db": "workspace:*", + "@wrnexus/dev-toolbar": "workspace:*", "@wrnexus/i18n": "workspace:*", "@wrnexus/pubsub": "workspace:*", "@wrnexus/router": "workspace:*", @@ -97,58 +98,66 @@ "@wrnexus/validation": "workspace:*", }, }, + "packages/dev-toolbar": { + "name": "@wrnexus/dev-toolbar", + "version": "0.2.58", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.2", + }, + }, "packages/encryption": { "name": "@wrnexus/encryption", - "version": "0.2.17", + "version": "0.2.74", }, "packages/helpers": { "name": "@wrnexus/helpers", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/i18n": { "name": "@wrnexus/i18n", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/jwt": { "name": "@wrnexus/jwt", - "version": "0.2.17", + "version": "0.2.74", }, "packages/mobile": { "name": "@wrnexus/mobile", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/native": "workspace:*", }, }, "packages/native": { "name": "@wrnexus/native", - "version": "0.2.17", + "version": "0.2.74", }, "packages/oauth": { "name": "@wrnexus/oauth", - "version": "0.2.17", + "version": "0.2.74", }, "packages/pubsub": { "name": "@wrnexus/pubsub", - "version": "0.2.17", + "version": "0.2.74", }, "packages/queue": { "name": "@wrnexus/queue", - "version": "0.2.17", + "version": "0.2.74", }, "packages/reactive": { "name": "@wrnexus/reactive", - "version": "0.2.17", + "version": "0.2.74", }, "packages/router": { "name": "@wrnexus/router", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/compiler": "workspace:*", "@wrnexus/core": "workspace:*", @@ -156,43 +165,43 @@ }, "packages/ssr": { "name": "@wrnexus/ssr", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/styles": { "name": "@wrnexus/styles", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/uploader": "workspace:*", }, }, "packages/test": { "name": "@wrnexus/test", - "version": "0.2.17", + "version": "0.2.74", }, "packages/tracking": { "name": "@wrnexus/tracking", - "version": "0.2.17", + "version": "0.2.74", }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/uploader": { "name": "@wrnexus/uploader", - "version": "0.2.17", + "version": "0.2.74", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/validation": { "name": "@wrnexus/validation", - "version": "0.2.17", + "version": "0.2.74", }, }, "overrides": { @@ -445,6 +454,8 @@ "@wrnexus/dev-server": ["@wrnexus/dev-server@workspace:packages/dev-server"], + "@wrnexus/dev-toolbar": ["@wrnexus/dev-toolbar@workspace:packages/dev-toolbar"], + "@wrnexus/encryption": ["@wrnexus/encryption@workspace:packages/encryption"], "@wrnexus/helpers": ["@wrnexus/helpers@workspace:packages/helpers"], diff --git a/packages/ai/package.json b/packages/ai/package.json index c19fcd98..df59f041 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.74", + "version": "0.2.75", "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 85f0a82b..9b129f8d 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9384650a..c9e3b9ec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 83055865..63f5cca7 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -743,6 +743,14 @@ const MIGRATIONS: Migration[] = [ // Language and shared UI behavior improve automatically after updating. }, }, + { + version: "0.2.75", + id: "dev-toolbar", + description: "Dev toolbar for optimization and developer.", + apply() { + // Language and shared UI behavior improve automatically after updating. + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/compiler/package.json b/packages/compiler/package.json index e9876143..b0969490 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index a1102d2d..8e5b2391 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index fb93b289..a1447dce 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 31caaa34..cef5e4ae 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index f546baed..9d63d85a 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { @@ -9,6 +9,7 @@ }, "dependencies": { "@wrnexus/core": "workspace:*", + "@wrnexus/dev-toolbar": "workspace:*", "@wrnexus/router": "workspace:*", "@wrnexus/ssr": "workspace:*", "@wrnexus/csr": "workspace:*", diff --git a/packages/dev-server/src/hmr.ts b/packages/dev-server/src/hmr.ts index 5859a004..09b7b404 100644 --- a/packages/dev-server/src/hmr.ts +++ b/packages/dev-server/src/hmr.ts @@ -31,6 +31,17 @@ export class HmrHub { this.sockets.delete(ws); } + broadcastJson(message: unknown): void { + const payload = JSON.stringify(message); + for (const ws of this.sockets) { + try { + ws.send(payload); + } catch { + this.sockets.delete(ws); + } + } + } + broadcast(message: HmrMessage): void { const payload = JSON.stringify(message); for (const ws of this.sockets) { diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 8d7ce62b..9518c222 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -31,6 +31,10 @@ import { startWatcher } from "./watch.ts"; export { RESTART_EXIT_CODE } from "./restart.ts"; import { resetDevCache } from "./cache.ts"; +import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types"; + +import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server"; + export interface ServeOptions { appDir: string; port?: number; @@ -62,6 +66,7 @@ export interface ServeOptions { storage?: StorageConfig; mobile?: MobileConfig; pwa?: PwaConfig | false; + devToolbar?: boolean | DevToolbarConfig; } export interface RunningServer { @@ -112,6 +117,40 @@ async function schemaRuntime(router: Router): Promise { return renderSchemasScript(descriptors); } +function resolveDevToolbarConfig( + mode: string, + value: ServeOptions["devToolbar"], +): DevToolbarConfig | null { + if (mode !== "development" || value === false) { + return null; + } + + if (value === true || value === undefined) { + return { + enabled: true, + position: "bottom-center", + defaultOpen: false, + scanOnNavigation: true, + scanOnHmr: true, + openEditor: true, + }; + } + + if (value.enabled === false) { + return null; + } + + return { + enabled: true, + position: "bottom-center", + defaultOpen: false, + scanOnNavigation: true, + scanOnHmr: true, + openEditor: true, + ...value, + }; +} + export async function startServer(opts: ServeOptions): Promise { const appDir = resolve(opts.appDir); const mode: Mode = opts.mode ?? "development"; @@ -124,6 +163,12 @@ export async function startServer(opts: ServeOptions): Promise { const styleEntry = opts.styleEntry ?? null; const appRoot = dirname(appDir); + const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar); + + const devToolbarCollector: DevToolbarCollector | undefined = devToolbarConfig + ? createDevToolbarCollector() + : undefined; + resetDevCache({ rootDir: appRoot, enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1", @@ -187,6 +232,12 @@ export async function startServer(opts: ServeOptions): Promise { ); const hub = hmr ? new HmrHub() : undefined; + const unsubscribeDevToolbar = + devToolbarCollector && hub + ? devToolbarCollector.subscribe((issues) => { + hub.broadcastJson({ channel: "toolbar", type: "toolbar:issues", issues }); + }) + : undefined; const middleware = middlewareLoader(router); const runtimeDeps = { @@ -207,6 +258,14 @@ export async function startServer(opts: ServeOptions): Promise { security: opts.security, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), + devToolbar: + devToolbarConfig && devToolbarCollector + ? { + config: devToolbarConfig, + collector: devToolbarCollector, + root: appRoot, + } + : undefined, }; const handlers = createHandlers(runtimeDeps); @@ -252,8 +311,20 @@ export async function startServer(opts: ServeOptions): Promise { console.log(`[wrnexus] hot update — ${files.join(", ")}`); hub.reload(); + hub.broadcastJson({ + channel: "toolbar", + type: "toolbar:scan", + reason: "source-change", + files, + }); }; - watcher = startWatcher({ appDir, hub, assets, onHotChange: hotUpdate }); + watcher = startWatcher({ + appDir, + hub, + assets, + devToolbarCollector, + onHotChange: hotUpdate, + }); } const boundPort = server.port ?? port; @@ -264,6 +335,7 @@ export async function startServer(opts: ServeOptions): Promise { router, stop: () => { watcher?.close(); + unsubscribeDevToolbar?.(); server.stop(); }, }; diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 6f1d26ab..8659c8d0 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -54,6 +54,14 @@ import { } from "@wrnexus/i18n"; import { runMiddleware } from "./pipeline.ts"; import type { HmrHub } from "./hmr.ts"; +import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types"; + +import { + createServerIssue, + handleDevToolbarRoute, + issueFromError, + type DevToolbarCollector, +} from "@wrnexus/dev-toolbar/server"; /** HTTP methods we recognize as API handler exports. */ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const; @@ -131,6 +139,32 @@ export interface RuntimeDeps { * bus (use the Redis pub/sub driver). Enables realtime across multiple apps. */ realtimeBus?: RealtimeBus; + + devToolbar?: { + config: DevToolbarConfig; + collector: DevToolbarCollector; + root: string; + }; +} + +const DEV_TOOLBAR_SCRIPT = + ''; + +function shouldEnableDevToolbar(mode: string, deps: RuntimeDeps): boolean { + return ( + mode === "development" && + deps.devToolbar !== undefined && + deps.devToolbar.config.enabled !== false + ); +} + +function shouldReportNotFound(pathname: string): boolean { + return !( + pathname.startsWith("/__wrnexus/") || + pathname.startsWith("/.well-known/") || + pathname === "/favicon.ico" || + pathname.endsWith(".map") + ); } export const PWA_CLIENT = `if ("serviceWorker" in navigator) { @@ -429,8 +463,12 @@ export const HMR_CLIENT_JS = ` }; ws.onmessage = function (e) { var msg; try { msg = JSON.parse(e.data); } catch (_) { return; } - if (msg.type === "css") swapCss(msg.version); - else if (msg.type === "reload") requestSync(); + if (msg.channel === "toolbar") { + window.dispatchEvent(new CustomEvent("wrnexus:toolbar-message", { detail: msg })); + } else if (msg.type === "css") { + swapCss(msg.version); + window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: msg })); + } else if (msg.type === "reload") requestSync(); else if (msg.type === "html") applyHtml(msg.html); else if (msg.type === "error") console.error("[wrnexus] HMR update failed:", msg.message); }; @@ -479,6 +517,7 @@ export const HMR_CLIENT_JS = ` // their reactive scopes (and any browser-side API fetches) in place. if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document); if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document); + window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: { type: "html" } })); } // Minimal index-based DOM morph: preserve matching nodes (keeps state/focus), @@ -717,6 +756,17 @@ export function createHandlers(deps: RuntimeDeps): Handlers { return secure(new Response("Payload Too Large", { status: 413 })); } + if (deps.devToolbar) { + const toolbarResponse = await handleDevToolbarRoute(req, { + mode, + root: deps.devToolbar.root, + collector: deps.devToolbar.collector, + editor: deps.devToolbar.config.editor, + allowOpenEditor: deps.devToolbar.config.openEditor !== false, + }); + if (toolbarResponse) return secure(toolbarResponse); + } + // --- HMR socket (dev only): upgrade before anything else. --- if (hmr && url.pathname === "/__wrnexus/hmr") { if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin()); @@ -795,6 +845,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers { console.error( `[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`, ); + deps.devToolbar?.collector.add( + issueFromError(err, { + ruleId: "server/request-error", + category: "server", + title: "Request processing failed", + pathname: url.pathname, + }), + ); const response = secure(renderError(err, mode)); // Gateway-managed production apps bind to loopback. Carry a bounded, // encoded diagnostic to the parent gateway so centralized log collectors @@ -1004,6 +1062,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers { result += await renderComponents(rendered, translate, depth + 1); } catch (err) { console.error(`[wrnexus] component '${name}' failed to render`, err); + deps.devToolbar?.collector.add( + issueFromError(err, { + ruleId: "server/component-render-error", + category: "server", + title: `Component '${name}' failed to render`, + source: { file: component.file, component: name }, + }), + ); result += body.slice(tagStart, end); } } @@ -1018,6 +1084,20 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ); const matched = router.matchPage(ctx.url.pathname); if (!matched) { + if (deps.devToolbar && shouldReportNotFound(ctx.url.pathname)) { + deps.devToolbar.collector.add( + createServerIssue({ + ruleId: "routing/not-found", + category: "routing", + severity: "warning", + title: "Route not found", + message: `No page route matched ${ctx.url.pathname}.`, + pathname: ctx.url.pathname, + recommendation: + "Verify the URL, page filename, dynamic route parameters, and route casing.", + }), + ); + } const response = renderNotFound(); if (!isMobileRequest) return response; const headers = new Headers(response.headers); @@ -1065,6 +1145,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers { } } catch (err) { console.error(`[wrnexus] layout '${layoutName}' failed to render`, err); + deps.devToolbar?.collector.add( + issueFromError(err, { + ruleId: "server/layout-render-error", + category: "server", + title: `Layout '${layoutName}' failed to render`, + pathname: ctx.url.pathname, + source: { file: layout.file }, + }), + ); } } @@ -1102,7 +1191,13 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ] .filter(Boolean) .join("\n "), - extraBody: hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : undefined, + extraBody: + [ + hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "", + shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "", + ] + .filter(Boolean) + .join("\n") || undefined, htmlAttrs, }); // Conditional GET: hash the page CONTENT (`body`), not the assembled shell — @@ -1121,6 +1216,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers { "content-type": "text/html; charset=utf-8", etag: tag, "cache-control": "private, no-cache", + ...(shouldEnableDevToolbar(mode, deps) + ? { + "x-wrnexus-dev-toolbar": "enabled", + "x-wrnexus-route": matched.route.raw, + } + : {}), }, }); } diff --git a/packages/dev-server/src/serve-entry.ts b/packages/dev-server/src/serve-entry.ts index 636be807..62c99656 100644 --- a/packages/dev-server/src/serve-entry.ts +++ b/packages/dev-server/src/serve-entry.ts @@ -42,6 +42,7 @@ const server = await startServer({ storage: config.storage, mobile: config.mobile, pwa: config.pwa, + devToolbar: config.devToolbar, }); const r = server.router; diff --git a/packages/dev-server/src/watch.ts b/packages/dev-server/src/watch.ts index f040fb08..ceb3856b 100644 --- a/packages/dev-server/src/watch.ts +++ b/packages/dev-server/src/watch.ts @@ -11,11 +11,13 @@ import { watch, type FSWatcher } from "node:fs"; import type { HmrHub } from "./hmr.ts"; import type { DevAssetServer } from "./assets.ts"; +import type { DevToolbarCollector } from "@wrnexus/dev-toolbar/server"; export interface WatchOptions { appDir: string; hub: HmrHub; assets: DevAssetServer; + devToolbarCollector?: DevToolbarCollector; /** Called with changed app-relative files that need an in-process hot update. */ onHotChange: (files: string[]) => void | Promise; } @@ -50,6 +52,7 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined { pending.clear(); const files = [...pendingFiles]; pendingFiles.clear(); + for (const file of files) opts.devToolbarCollector?.clear(file); void Promise.resolve(opts.onHotChange(files)).catch((error) => { console.error("[wrnexus] hot update failed", error); }); @@ -58,6 +61,12 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined { if (pending.has("css")) { assets.invalidateCss(); hub.css(); + hub.broadcastJson({ + channel: "toolbar", + type: "toolbar:scan", + reason: "styles-change", + files: [...pendingFiles], + }); } pending.clear(); pendingFiles.clear(); diff --git a/packages/dev-toolbar/INTEGRATION.md b/packages/dev-toolbar/INTEGRATION.md new file mode 100644 index 00000000..43b50bfd --- /dev/null +++ b/packages/dev-toolbar/INTEGRATION.md @@ -0,0 +1,78 @@ +# WRNexusJS DevServer integration + +## 1. Add workspace dependency + +```json +"@wrnexus/dev-toolbar": "workspace:*" +``` + +## 2. Create a collector when development starts + +```ts +import { createDevToolbarCollector } from "@wrnexus/dev-toolbar/server"; + +const collector = mode === "development" ? createDevToolbarCollector() : undefined; +``` + +## 3. Handle toolbar routes before public-file fallback + +```ts +import { handleDevToolbarRoute } from "@wrnexus/dev-toolbar/server"; + +const toolbarResponse = collector + ? await handleDevToolbarRoute(request, { + mode, + root: appRoot, + collector, + editor: config.devToolbar?.editor, + allowOpenEditor: config.devToolbar?.openEditor !== false, + }) + : null; + +if (toolbarResponse) return toolbarResponse; +``` + +## 4. Inject toolbar in full HTML documents + +```ts +function injectDevToolbar(html: string): string { + if (mode !== "development") return html; + const script = + ''; + return html.includes("") + ? html.replace("", `${script}`) + : `${html}${script}`; +} +``` + +Do not inject into APIs, JSON, XML, assets, downloads, or navigation fragments. Keep the toolbar outside `#app` so CSR navigation does not destroy it. + +## 5. Add server errors + +```ts +import { issueFromError } from "@wrnexus/dev-toolbar/server"; + +try { + return await renderPage(); +} catch (error) { + collector?.add( + issueFromError(error, { + ruleId: "server/render-error", + category: "server", + title: "Page rendering failed", + pathname: new URL(request.url).pathname, + }), + ); + throw error; +} +``` + +## 6. Notify browser after HMR/navigation + +Dispatch one of these events from existing runtimes: + +```ts +window.dispatchEvent(new CustomEvent("wrnexus:hmr")); +window.dispatchEvent(new CustomEvent("wrnexus:navigated", { detail: { pathname } })); +window.dispatchEvent(new CustomEvent("wrnexus:runtime-error", { detail: { message, stack } })); +``` diff --git a/packages/dev-toolbar/README.md b/packages/dev-toolbar/README.md new file mode 100644 index 00000000..7b07b04c --- /dev/null +++ b/packages/dev-toolbar/README.md @@ -0,0 +1,23 @@ +# @wrnexus/dev-toolbar + +Development-only page quality toolbar for WRNexusJS. + +## Features + +- Runtime, resource and unhandled promise error capture +- Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks +- Performance and network observations +- Element highlighting and issue filtering +- Server-side issue collector +- Development-only asset strings for direct serving by `@wrnexus/dev-server` +- Safe open-in-editor helper + +## Dev-server integration + +Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS` at `/__wrnexus/dev-toolbar.css`, then inject this before `` in development: + +```html + +``` + +The browser runtime exposes `window.__wrnexusDevToolbar`. diff --git a/packages/dev-toolbar/package.json b/packages/dev-toolbar/package.json new file mode 100644 index 00000000..b6c23e0b --- /dev/null +++ b/packages/dev-toolbar/package.json @@ -0,0 +1,25 @@ +{ + "name": "@wrnexus/dev-toolbar", + "version": "0.2.75", + "private": true, + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client/index.ts", + "./server": "./src/server/index.ts", + "./types": "./src/types.ts", + "./rules": "./src/rules/index.ts" + }, + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run test" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.2" + } +} diff --git a/packages/dev-toolbar/src/client/index.ts b/packages/dev-toolbar/src/client/index.ts new file mode 100644 index 00000000..a5cf0ee8 --- /dev/null +++ b/packages/dev-toolbar/src/client/index.ts @@ -0,0 +1,2 @@ +export { DEV_TOOLBAR_RUNTIME } from "./runtime.ts"; +export { DEV_TOOLBAR_CSS } from "./styles.ts"; diff --git a/packages/dev-toolbar/src/client/runtime.ts b/packages/dev-toolbar/src/client/runtime.ts new file mode 100644 index 00000000..8dd73e1d --- /dev/null +++ b/packages/dev-toolbar/src/client/runtime.ts @@ -0,0 +1,58 @@ +export const DEV_TOOLBAR_RUNTIME = String.raw`(() => { + if (window.__wrnexusDevToolbar) return; + const KEY = "__wrnexus_dev_toolbar_settings__"; + const state = { open:false, issues:[], report:null, search:"", config:{ position:"bottom-center", scanOnNavigation:true, scanOnHmr:true } }; + try { state.config = Object.assign(state.config, JSON.parse(localStorage.getItem(KEY) || "{}")); } catch {} + const host = document.createElement("wrnexus-dev-toolbar"); + host.setAttribute("data-wrnexus-dev-toolbar", ""); + document.body.appendChild(host); + const root = host.attachShadow({mode:"open"}); + const style = document.createElement("style"); + fetch("/__wrnexus/dev-toolbar.css").then(r => r.ok ? r.text() : "").then(css => style.textContent = css).catch(() => {}); + root.appendChild(style); + const shell = document.createElement("div"); shell.className="wrn-shell"; shell.dataset.position=state.config.position; + shell.innerHTML='
WRNexus DevToolbar
'; + root.appendChild(shell); + const panel=root.querySelector(".wrn-panel"), list=root.querySelector(".wrn-list"), meta=root.querySelector(".wrn-panel-meta"), search=root.querySelector(".wrn-search"); + const esc = value => String(value ?? "").replace(/[&<>\"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[char]); + const selector = el => { if (!el || el.nodeType !== 1) return ""; if (el.id) return "#"+CSS.escape(el.id); const parts=[]; let cur=el; while(cur && cur!==document.documentElement && parts.length<5){ let part=cur.tagName.toLowerCase(); const cls=[...cur.classList].slice(0,2).map(x=>"."+CSS.escape(x)).join(""); part+=cls; const parent=cur.parentElement; if(parent){ const same=[...parent.children].filter(x=>x.tagName===cur.tagName); if(same.length>1) part+=":nth-of-type("+(same.indexOf(cur)+1)+")"; } parts.unshift(part); cur=parent; } return parts.join(" > "); }; + const issue = (ruleId,category,severity,title,message,el,recommendation,confidence="high",metadata={}) => { const sel=selector(el); const source=el?.getAttribute?.("data-wrnexus-source")||""; return { id:"wrn-"+Date.now()+"-"+Math.random().toString(36).slice(2), ruleId,category,severity,title,message,recommendation,confidence,target:el?{selector:sel,tagName:el.tagName.toLowerCase(),text:(el.textContent||"").trim().slice(0,120)}:undefined,source:source?{file:source}:undefined,metadata,fingerprint:[ruleId,location.pathname,sel,source,message].join("|"),createdAt:Date.now() }; }; + const accessibleName = el => { const by=el.getAttribute("aria-labelledby"); if(by){ const t=by.split(/\s+/).map(id=>document.getElementById(id)?.textContent?.trim()||"").join(" ").trim(); if(t)return t; } return el.getAttribute("aria-label")?.trim()||el.getAttribute("alt")?.trim()||el.getAttribute("title")?.trim()||el.textContent?.trim()||""; }; + const visible = el => { const s=getComputedStyle(el),r=el.getBoundingClientRect(); return s.display!=="none"&&s.visibility!=="hidden"&&Number(s.opacity)!==0&&r.width>0&&r.height>0; }; + const scan = async () => { + const found=[]; const q=s=>[...document.querySelectorAll(s)].filter(el=>!el.closest("wrnexus-dev-toolbar")&&!el.closest("[data-dev-toolbar-ignore='all']")); + q("img:not([alt])").forEach(el=>found.push(issue("a11y/image-alt","accessibility","error","Image is missing alt text","Add alt text for meaningful images, or alt=\"\" for decorative images.",el,"Add a concise alt attribute."))); + q("button,a[href],[role='button']").filter(el=>!accessibleName(el)).forEach(el=>found.push(issue("a11y/control-name","accessibility","error","Control has no accessible name","Screen readers cannot identify this control.",el,"Add visible text, aria-label, or aria-labelledby."))); + q("input:not([type='hidden']),select,textarea").filter(el=>{const id=el.id;return !el.getAttribute("aria-label")&&!el.getAttribute("aria-labelledby")&&!(id&&document.querySelector("label[for='"+CSS.escape(id)+"']"))&&!el.closest("label")}).forEach(el=>found.push(issue("a11y/form-label","accessibility","error","Form control has no label","Users may not know what this field is for.",el,"Associate a visible label."))); + const ids=new Set(); q("[id]").forEach(el=>{if(ids.has(el.id))found.push(issue("a11y/duplicate-id","accessibility","error","Duplicate element ID","The ID “"+el.id+"” is used more than once.",el,"Use a unique ID.")); else ids.add(el.id)}); + let prev=0; q("h1,h2,h3,h4,h5,h6").forEach(el=>{const level=Number(el.tagName.slice(1));if(prev&&level>prev+1)found.push(issue("a11y/heading-order","accessibility","warning","Heading level is skipped","Heading jumps from h"+prev+" to h"+level+".",el,"Use a logical heading hierarchy."));prev=level}); + if(!document.title.trim()) found.push(issue("seo/title-missing","seo","error","Page title is missing","The document does not have a useful title.",null,"Add a unique title.")); + if(!document.querySelector('meta[name="description"]')) found.push(issue("seo/description-missing","seo","warning","Meta description is missing","Search and social previews may lack a useful description.",null,"Add a unique meta description.")); + if(!document.querySelector('meta[name="viewport"]')) found.push(issue("seo/viewport-missing","seo","error","Viewport meta tag is missing","The page may render incorrectly on mobile devices.",null,"Add width=device-width, initial-scale=1.")); + if(!document.querySelector("h1")) found.push(issue("seo/h1-missing","seo","warning","Page has no h1 heading","The page lacks a clear primary heading.",null,"Add one descriptive h1.")); + q("img").forEach(el=>{const r=el.getBoundingClientRect();if(el.complete&&el.naturalWidth===0)found.push(issue("images/broken","images","error","Image failed to load",el.currentSrc||el.src||"Empty image source",el,"Verify the URL and content type."));if(!el.hasAttribute("width")&&!el.hasAttribute("height")&&getComputedStyle(el).aspectRatio==="auto")found.push(issue("images/dimensions","images","warning","Image has no reserved dimensions","This image may cause layout shift.",el,"Add width and height or CSS aspect-ratio."));if(visible(el)&&el.naturalWidth>0&&r.width>el.naturalWidth*1.25)found.push(issue("images/upscaled","images","warning","Image is being enlarged","Rendered width exceeds intrinsic width.",el,"Use a larger source image."));if(r.top>innerHeight*1.5&&el.loading!=="lazy")found.push(issue("images/lazy-below-fold","images","suggestion","Below-fold image is not lazy loaded","This image starts far below the viewport.",el,"Consider loading=\"lazy\".","medium"));}); + q("video").forEach(el=>{if(el.autoplay&&!el.muted)found.push(issue("media/autoplay-sound","media","error","Autoplay video is not muted","Autoplaying sound is disruptive and often blocked.",el,"Remove autoplay or add muted."));if(!el.querySelector('track[kind="captions"]'))found.push(issue("media/no-captions","media","warning","Video has no captions track","Spoken content may be inaccessible.",el,"Add a captions track when applicable."));}); + q("iframe:not([title])").forEach(el=>found.push(issue("media/iframe-title","media","error","Iframe is missing a title","Assistive technology cannot identify the embed.",el,"Add a concise title."))); + const count=document.querySelectorAll("*").length;if(count>1500)found.push(issue("html/dom-size","html",count>3000?"error":"warning","DOM is large","The page contains "+count+" elements.",null,"Reduce wrappers and render hidden content on demand.")); + q("button button,button a,a a,a button").forEach(el=>found.push(issue("html/nested-interactive","html","error","Interactive elements are nested","Nested links or buttons create invalid interaction behavior.",el,"Use one interactive element."))); + q("a").forEach(el=>{const href=el.getAttribute("href");if(!href||href==="#")found.push(issue("links/empty","links","warning","Link has no useful destination","The href is missing or only #.",el,"Use a real URL or a button."));if(el.target==="_blank"&&!/\b(noopener|noreferrer)\b/.test(el.rel))found.push(issue("links/blank-rel","security","warning","New-tab link lacks rel protection","The opened page may access window.opener.",el,"Add rel=\"noopener noreferrer\"."));}); + if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media.")); + q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));}); + const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources.")); + const unique=new Map();[...state.issues.filter(x=>["runtime","network","server","compiler","routing"].includes(x.category)),...found].forEach(x=>unique.set(x.fingerprint,x));state.issues=[...unique.values()]; + state.report={url:location.href,pathname:location.pathname,title:document.title,status:200,generatedAt:Date.now(),issues:state.issues,metrics:{domNodes:count,cssResources:q('link[rel="stylesheet"]').length,scriptResources:q("script[src]").length,imageResources:q("img").length,totalTransferBytes:total,pageLoadMs:performance.getEntriesByType("navigation")[0]?.duration}}; render(); return state.report; + }; + let highlightEl=null; const clearHighlight=()=>{highlightEl?.remove();highlightEl=null}; const highlight=sel=>{clearHighlight();let target;try{target=document.querySelector(sel)}catch{}if(!target)return;const r=target.getBoundingClientRect();highlightEl=document.createElement("div");highlightEl.className="wrn-highlight";Object.assign(highlightEl.style,{left:r.left+"px",top:r.top+"px",width:r.width+"px",height:r.height+"px"});root.appendChild(highlightEl);target.scrollIntoView({block:"center",behavior:"smooth"});setTimeout(clearHighlight,3000)}; + const render=()=>{const filtered=state.issues.filter(x=>!state.search||[x.title,x.message,x.ruleId,x.category,x.source?.file].join(" ").toLowerCase().includes(state.search));root.querySelector("[data-errors]").textContent=state.issues.filter(x=>x.severity==="error").length;root.querySelector("[data-warnings]").textContent=state.issues.filter(x=>x.severity==="warning").length;root.querySelector("[data-suggestions]").textContent=state.issues.filter(x=>x.severity==="suggestion").length;meta.textContent=location.pathname+" · "+state.issues.length+" issues";if(!filtered.length){list.innerHTML='
No matching issues. Run a fresh scan after changing the page.
';return;}list.innerHTML=filtered.map(x=>'
'+esc(x.title)+'
'+esc(x.message)+'
'+esc(x.category)+' · '+esc(x.ruleId)+(x.source?.file?' · '+esc(x.source.file):'')+'
'+(x.target?.selector?'':'')+'
').join("");}; + const addRuntime=(severity,title,message,metadata={})=>{const x=issue("runtime/browser","runtime",severity,title,message,null,"Inspect the browser console and source stack.","high",metadata);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render()}; + const mergeServerIssues=issues=>{const unique=new Map(state.issues.map(x=>[x.fingerprint,x]));(issues||[]).forEach(x=>unique.set(x.fingerprint,x));state.issues=[...unique.values()];render()}; + const loadServerIssues=()=>fetch("/__wrnexus/dev-toolbar/issues?pathname="+encodeURIComponent(location.pathname),{headers:{accept:"application/json"}}).then(r=>r.ok?r.json():{issues:[]}).then(data=>mergeServerIssues(data.issues)).catch(()=>{}); + addEventListener("error",event=>{const target=event.target;if(target&&target!==window&&target.tagName){addRuntime("error","Resource failed to load",target.src||target.href||target.currentSrc||target.tagName,{tag:target.tagName});}else addRuntime("error",event.message||"Uncaught browser error",event.filename?event.filename+":"+event.lineno+":"+event.colno:"",{stack:event.error?.stack});},true); + addEventListener("unhandledrejection",event=>addRuntime("error","Unhandled promise rejection",event.reason?.message||String(event.reason),{stack:event.reason?.stack})); + root.addEventListener("click",event=>{const button=event.target.closest("button");if(!button)return;if(button.matches("[data-toggle]")){state.open=!state.open;panel.classList.toggle("open",state.open)}if(button.matches("[data-close]")){state.open=false;panel.classList.remove("open")}if(button.matches("[data-scan]"))scan();if(button.matches("[data-clear]")){state.issues=[];render()}if(button.dataset.filter){state.search=button.dataset.filter;search.value=state.search;state.open=true;panel.classList.add("open");render()}if(button.dataset.highlight)highlight(button.dataset.highlight)}); + search.addEventListener("input",()=>{state.search=search.value.toLowerCase();render()}); + addEventListener("wrnexus:navigated",()=>{if(state.config.scanOnNavigation)setTimeout(()=>{loadServerIssues();scan()},50)});addEventListener("wrnexus:hmr",()=>{if(state.config.scanOnHmr)setTimeout(()=>{loadServerIssues();scan()},100)});addEventListener("wrnexus:toolbar-message",event=>{const message=event.detail||{};if(message.type==="toolbar:issues")mergeServerIssues(message.issues);if(message.type==="toolbar:scan"&&state.config.scanOnHmr)setTimeout(scan,50)});addEventListener("wrnexus:runtime-error",event=>addRuntime("error","WRNexus runtime error",event.detail?.message||"Runtime failure",event.detail||{})); + window.__wrnexusDevToolbar={open(){state.open=true;panel.classList.add("open")},close(){state.open=false;panel.classList.remove("open")},toggle(){state.open=!state.open;panel.classList.toggle("open",state.open)},scan,clear(){state.issues=[];render()},report(){return state.report},highlight,configure(config){state.config=Object.assign(state.config,config||{});shell.dataset.position=state.config.position;try{localStorage.setItem(KEY,JSON.stringify(state.config))}catch{}}}; + const observer=new MutationObserver(()=>{clearTimeout(observer.timer);observer.timer=setTimeout(()=>{if(!state.open)return;scan()},400)});observer.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style","src","href","alt","aria-label"]}); + setTimeout(()=>{loadServerIssues();scan()},100); +})();`; diff --git a/packages/dev-toolbar/src/client/styles.ts b/packages/dev-toolbar/src/client/styles.ts new file mode 100644 index 00000000..b506d942 --- /dev/null +++ b/packages/dev-toolbar/src/client/styles.ts @@ -0,0 +1,4 @@ +export const DEV_TOOLBAR_CSS = String.raw` +:host{all:initial;color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#111318;--panel:#181b22;--line:#2b303b;--text:#f4f6fb;--muted:#9ca6b8;--error:#ff6b6b;--warning:#ffc857;--info:#72a7ff;--suggestion:#a78bfa} +*{box-sizing:border-box}.wrn-shell{position:fixed;z-index:2147483646;bottom:16px;left:50%;transform:translateX(-50%);color:var(--text);font-size:13px;line-height:1.4}.wrn-shell[data-position="bottom-left"]{left:16px;transform:none}.wrn-shell[data-position="bottom-right"]{left:auto;right:16px;transform:none}.wrn-bar{display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:14px;background:rgba(17,19,24,.96);box-shadow:0 18px 60px rgba(0,0,0,.42);backdrop-filter:blur(18px)}button{appearance:none;border:0;font:inherit}.wrn-button,.wrn-count{display:inline-flex;align-items:center;justify-content:center;min-height:32px;border-radius:9px;padding:0 10px;background:#222631;color:var(--text);cursor:pointer}.wrn-button:hover,.wrn-count:hover{background:#2b303c}.wrn-button:focus-visible,.wrn-count:focus-visible{outline:2px solid #72a7ff;outline-offset:2px}.wrn-brand{font-weight:800;letter-spacing:-.02em;padding:0 8px}.wrn-count{gap:5px;font-variant-numeric:tabular-nums}.wrn-dot{width:7px;height:7px;border-radius:999px}.wrn-dot.error{background:var(--error)}.wrn-dot.warning{background:var(--warning)}.wrn-dot.suggestion{background:var(--suggestion)}.wrn-panel{position:absolute;bottom:50px;left:50%;transform:translateX(-50%);width:min(780px,calc(100vw - 24px));height:min(620px,calc(100vh - 100px));display:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:var(--bg);box-shadow:0 24px 80px rgba(0,0,0,.5)}.wrn-panel.open{display:grid;grid-template-rows:auto auto 1fr}.wrn-panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.wrn-panel-title{font-size:15px;font-weight:800}.wrn-panel-meta{color:var(--muted);font-size:12px}.wrn-toolbar-row{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line)}.wrn-search{width:100%;height:34px;border:1px solid var(--line);border-radius:9px;background:#20242d;color:var(--text);padding:0 10px;outline:none}.wrn-search:focus{border-color:#72a7ff}.wrn-list{overflow:auto;padding:10px}.wrn-empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:40px}.wrn-issue{display:grid;grid-template-columns:8px 1fr auto;gap:10px;padding:12px;margin-bottom:8px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.wrn-severity{border-radius:999px;background:var(--info)}.wrn-severity.error{background:var(--error)}.wrn-severity.warning{background:var(--warning)}.wrn-severity.suggestion{background:var(--suggestion)}.wrn-issue-title{font-weight:750}.wrn-issue-message{margin-top:4px;color:#c8cfdb}.wrn-issue-meta{margin-top:7px;color:var(--muted);font-size:11px}.wrn-actions{display:flex;gap:5px}.wrn-small{height:28px;padding:0 8px;border-radius:7px;background:#252a35;color:var(--text);cursor:pointer}.wrn-small:hover{background:#303644}.wrn-highlight{position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #72a7ff;background:rgba(114,167,255,.12);box-shadow:0 0 0 99999px rgba(0,0,0,.08)}@media(max-width:640px){.wrn-shell{bottom:8px}.wrn-brand{display:none}.wrn-bar{gap:3px;padding:5px}.wrn-count{padding:0 7px}.wrn-panel{bottom:44px;height:calc(100vh - 62px)}.wrn-issue{grid-template-columns:6px 1fr}.wrn-actions{grid-column:2}.wrn-panel-meta{display:none}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} +`; diff --git a/packages/dev-toolbar/src/index.ts b/packages/dev-toolbar/src/index.ts new file mode 100644 index 00000000..af3d1244 --- /dev/null +++ b/packages/dev-toolbar/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types.ts"; +export * from "./rules/index.ts"; +export * from "./server/index.ts"; +export { DEV_TOOLBAR_RUNTIME, DEV_TOOLBAR_CSS } from "./client/index.ts"; diff --git a/packages/dev-toolbar/src/rules/accessibility.ts b/packages/dev-toolbar/src/rules/accessibility.ts new file mode 100644 index 00000000..7d38f44a --- /dev/null +++ b/packages/dev-toolbar/src/rules/accessibility.ts @@ -0,0 +1,143 @@ +import type { DevToolbarRule } from "./types.ts"; +import { accessibleName, createIssue } from "./helpers.ts"; + +export const accessibilityRules: DevToolbarRule[] = [ + { + id: "a11y/image-alt", + category: "accessibility", + defaultSeverity: "error", + description: "Images need alternative text.", + run: ({ root }) => + [...root.querySelectorAll("img:not([alt])")].map((element) => + createIssue({ + ruleId: "a11y/image-alt", + category: "accessibility", + severity: "error", + title: "Image is missing alt text", + message: 'Add alt text for meaningful images, or alt="" for decorative images.', + element, + recommendation: "Describe the image's purpose in a concise alt attribute.", + }), + ), + }, + { + id: "a11y/control-name", + category: "accessibility", + defaultSeverity: "error", + description: "Interactive controls need accessible names.", + run: ({ root }) => + [...root.querySelectorAll("button, a[href], [role='button']")] + .filter((element) => !accessibleName(element)) + .map((element) => + createIssue({ + ruleId: "a11y/control-name", + category: "accessibility", + severity: "error", + title: "Control has no accessible name", + message: "Screen readers cannot identify this control.", + element, + recommendation: "Add visible text, aria-label, or aria-labelledby.", + }), + ), + }, + { + id: "a11y/form-label", + category: "accessibility", + defaultSeverity: "error", + description: "Form controls need labels.", + run: ({ root }) => + [...root.querySelectorAll("input:not([type='hidden']), select, textarea")] + .filter((element) => { + const id = element.id; + return ( + !element.getAttribute("aria-label") && + !element.getAttribute("aria-labelledby") && + !(id && root.querySelector(`label[for='${CSS.escape(id)}']`)) && + !element.closest("label") + ); + }) + .map((element) => + createIssue({ + ruleId: "a11y/form-label", + category: "accessibility", + severity: "error", + title: "Form control has no label", + message: "Users of assistive technology may not know what this field is for.", + element, + recommendation: "Associate a label using for/id or aria-labelledby.", + }), + ), + }, + { + id: "a11y/duplicate-id", + category: "accessibility", + defaultSeverity: "error", + description: "IDs must be unique.", + run: ({ root }) => { + const seen = new Set(); + const duplicates: Element[] = []; + for (const element of root.querySelectorAll("[id]")) { + if (seen.has(element.id)) duplicates.push(element); + else seen.add(element.id); + } + return duplicates.map((element) => + createIssue({ + ruleId: "a11y/duplicate-id", + category: "accessibility", + severity: "error", + title: "Duplicate element ID", + message: `The ID “${element.id}” is used more than once.`, + element, + recommendation: "Use a unique ID for every element.", + }), + ); + }, + }, + { + id: "a11y/heading-order", + category: "accessibility", + defaultSeverity: "warning", + description: "Heading levels should not skip.", + run: ({ root }) => { + const issues = []; + let previous = 0; + for (const element of root.querySelectorAll("h1,h2,h3,h4,h5,h6")) { + const level = Number(element.tagName.slice(1)); + if (previous && level > previous + 1) + issues.push( + createIssue({ + ruleId: "a11y/heading-order", + category: "accessibility", + severity: "warning", + title: "Heading level is skipped", + message: `Heading jumps from h${previous} to h${level}.`, + element, + recommendation: "Use headings in a logical hierarchy.", + }), + ); + previous = level; + } + return issues; + }, + }, + { + id: "a11y/positive-tabindex", + category: "accessibility", + defaultSeverity: "warning", + description: "Positive tabindex disrupts natural keyboard order.", + run: ({ root }) => + [...root.querySelectorAll("[tabindex]")] + .filter((el) => Number(el.getAttribute("tabindex")) > 0) + .map((element) => + createIssue({ + ruleId: "a11y/positive-tabindex", + category: "accessibility", + severity: "warning", + title: "Positive tabindex used", + message: "Positive tabindex values create an unexpected keyboard focus order.", + element, + recommendation: 'Use tabindex="0" or rely on native document order.', + }), + ), + }, +]; diff --git a/packages/dev-toolbar/src/rules/color.ts b/packages/dev-toolbar/src/rules/color.ts new file mode 100644 index 00000000..f6884632 --- /dev/null +++ b/packages/dev-toolbar/src/rules/color.ts @@ -0,0 +1,48 @@ +import type { DevToolbarRule } from "./types.ts"; +import { contrastRatio, createIssue, effectiveBackground, isVisible, parseRgb } from "./helpers.ts"; + +export const colorRules: DevToolbarRule[] = [ + { + id: "color/contrast", + category: "color", + defaultSeverity: "warning", + description: "Checks basic text contrast against computed solid backgrounds.", + run: ({ root }) => { + const issues = []; + const candidates = root.querySelectorAll( + "p,span,a,button,label,input,textarea,select,h1,h2,h3,h4,h5,h6,li,td,th", + ); + for (const element of candidates) { + if ( + !isVisible(element) || + !(element.textContent?.trim() || element instanceof HTMLInputElement) + ) + continue; + const style = getComputedStyle(element); + const foreground = parseRgb(style.color); + const background = effectiveBackground(element); + if (!foreground || !background || foreground[3] < 0.95) continue; + const ratio = contrastRatio(foreground, background); + const fontSize = Number.parseFloat(style.fontSize); + const fontWeight = Number.parseInt(style.fontWeight, 10) || 400; + const large = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700); + const required = large ? 3 : 4.5; + if (ratio < required) + issues.push( + createIssue({ + ruleId: "color/contrast", + category: "color", + severity: ratio < 2 ? "error" : "warning", + title: "Text contrast is too low", + message: `Computed contrast is ${ratio.toFixed(2)}:1; this text generally needs at least ${required}:1.`, + element, + recommendation: "Increase the difference between text and background colors.", + confidence: "medium", + metadata: { ratio, required }, + }), + ); + } + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/forms.ts b/packages/dev-toolbar/src/rules/forms.ts new file mode 100644 index 00000000..c159d87a --- /dev/null +++ b/packages/dev-toolbar/src/rules/forms.ts @@ -0,0 +1,65 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue } from "./helpers.ts"; + +export const formRules: DevToolbarRule[] = [ + { + id: "forms/basics", + category: "forms", + defaultSeverity: "warning", + description: "Checks common form implementation problems.", + run: ({ root }) => { + const issues = []; + for (const input of root.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + >("input:not([type='hidden']),textarea,select")) { + if (!input.name) + issues.push( + createIssue({ + ruleId: "forms/name-missing", + category: "forms", + severity: "warning", + title: "Form control has no name", + message: "This value may not be included in native form submission.", + element: input, + recommendation: "Add a stable name attribute.", + }), + ); + if ( + input instanceof HTMLInputElement && + ["email", "tel", "password", "text"].includes(input.type) && + !input.autocomplete + ) + issues.push( + createIssue({ + ruleId: "forms/autocomplete", + category: "forms", + severity: "suggestion", + title: "Autocomplete is not configured", + message: "Browsers may not provide the most useful autofill behavior.", + element: input, + recommendation: "Add an appropriate autocomplete token.", + confidence: "medium", + }), + ); + } + for (const form of root.querySelectorAll("form")) { + if ( + (form.method || "get").toLowerCase() === "get" && + form.querySelector("input[type='password']") + ) + issues.push( + createIssue({ + ruleId: "forms/password-get", + category: "security", + severity: "error", + title: "Password form uses GET", + message: "Sensitive values can appear in URLs and logs.", + element: form, + recommendation: "Use POST for forms containing secrets.", + }), + ); + } + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/helpers.ts b/packages/dev-toolbar/src/rules/helpers.ts new file mode 100644 index 00000000..b55d007b --- /dev/null +++ b/packages/dev-toolbar/src/rules/helpers.ts @@ -0,0 +1,153 @@ +import type { DevToolbarCategory, DevToolbarIssue, DevToolbarSeverity } from "../types.ts"; + +let issueCounter = 0; + +export function getStableSelector(element: Element): string { + if (element.id) return `#${CSS.escape(element.id)}`; + const parts: string[] = []; + let current: Element | null = element; + while (current && current !== document.documentElement && parts.length < 5) { + let part = current.tagName.toLowerCase(); + const classes = [...current.classList] + .slice(0, 2) + .map((value) => `.${CSS.escape(value)}`) + .join(""); + part += classes; + const parent: Element | null = current.parentElement; + if (parent) { + const same = [...parent.children].filter((child) => child.tagName === current!.tagName); + if (same.length > 1) part += `:nth-of-type(${same.indexOf(current) + 1})`; + } + parts.unshift(part); + current = parent; + } + return parts.join(" > "); +} + +export function createFingerprint( + ruleId: string, + selector = "", + source = "", + message = "", +): string { + return `${ruleId}|${location.pathname}|${selector}|${source}|${message}`; +} + +export function createIssue(input: { + ruleId: string; + category: DevToolbarCategory; + severity: DevToolbarSeverity; + title: string; + message: string; + element?: Element; + recommendation?: string; + explanation?: string; + confidence?: "high" | "medium" | "low"; + metadata?: Record; +}): DevToolbarIssue { + const selector = input.element ? getStableSelector(input.element) : undefined; + const source = input.element?.getAttribute("data-wrnexus-source") ?? undefined; + return { + id: `wrn-issue-${Date.now()}-${++issueCounter}`, + ruleId: input.ruleId, + category: input.category, + severity: input.severity, + title: input.title, + message: input.message, + recommendation: input.recommendation, + explanation: input.explanation, + confidence: input.confidence ?? "high", + target: input.element + ? { + selector, + tagName: input.element.tagName.toLowerCase(), + id: input.element.id || undefined, + classes: [...input.element.classList], + text: input.element.textContent?.trim().slice(0, 120), + } + : undefined, + source: source ? parseSource(source) : undefined, + metadata: input.metadata, + fingerprint: createFingerprint(input.ruleId, selector, source, input.message), + createdAt: Date.now(), + }; +} + +export function parseSource(value: string) { + const match = /^(.*?):(\d+)(?::(\d+))?$/.exec(value); + if (!match) return { file: value }; + return { + file: match[1], + line: Number(match[2]), + column: match[3] ? Number(match[3]) : undefined, + }; +} + +export function isVisible(element: Element): boolean { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) !== 0 && + rect.width > 0 && + rect.height > 0 + ); +} + +export function accessibleName(element: Element): string { + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + const value = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .join(" ") + .trim(); + if (value) return value; + } + return ( + element.getAttribute("aria-label")?.trim() || + element.getAttribute("alt")?.trim() || + element.getAttribute("title")?.trim() || + element.textContent?.trim() || + "" + ); +} + +export function parseRgb(input: string): [number, number, number, number] | null { + const match = input.match(/rgba?\(([^)]+)\)/i); + if (!match) return null; + const parts = match[1] + .split(/[,/ ]+/) + .filter(Boolean) + .map(Number); + if (parts.length < 3 || parts.some(Number.isNaN)) return null; + return [parts[0], parts[1], parts[2], parts[3] ?? 1]; +} + +export function luminance([r, g, b]: [number, number, number, number]): number { + const values = [r, g, b].map((v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; +} + +export function contrastRatio( + a: [number, number, number, number], + b: [number, number, number, number], +): number { + const l1 = luminance(a); + const l2 = luminance(b); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +export function effectiveBackground(element: Element): [number, number, number, number] | null { + let current: Element | null = element; + while (current) { + const parsed = parseRgb(getComputedStyle(current).backgroundColor); + if (parsed && parsed[3] > 0.01) return parsed; + current = current.parentElement; + } + return [255, 255, 255, 1]; +} diff --git a/packages/dev-toolbar/src/rules/html.ts b/packages/dev-toolbar/src/rules/html.ts new file mode 100644 index 00000000..c903671f --- /dev/null +++ b/packages/dev-toolbar/src/rules/html.ts @@ -0,0 +1,74 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue } from "./helpers.ts"; + +export const htmlRules: DevToolbarRule[] = [ + { + id: "html/structure", + category: "html", + defaultSeverity: "warning", + description: "Checks document structure and DOM complexity.", + run: ({ document, root }) => { + const issues = []; + const nodeCount = root.querySelectorAll("*").length; + if (nodeCount > 3000) + issues.push( + createIssue({ + ruleId: "html/dom-size", + category: "html", + severity: "error", + title: "DOM is extremely large", + message: `The page contains ${nodeCount} elements.`, + recommendation: + "Reduce wrappers, paginate long lists, and render hidden content on demand.", + }), + ); + else if (nodeCount > 1500) + issues.push( + createIssue({ + ruleId: "html/dom-size", + category: "html", + severity: "warning", + title: "DOM is large", + message: `The page contains ${nodeCount} elements.`, + recommendation: "Review repeated wrappers and off-screen content.", + }), + ); + if (!document.querySelector("main")) + issues.push( + createIssue({ + ruleId: "html/main-missing", + category: "html", + severity: "warning", + title: "Main landmark is missing", + message: "The document has no main element.", + recommendation: "Wrap the primary page content in a main element.", + }), + ); + if (document.querySelectorAll("main").length > 1) + issues.push( + createIssue({ + ruleId: "html/multiple-main", + category: "html", + severity: "error", + title: "Multiple main landmarks", + message: "The document contains more than one main element.", + recommendation: "Use one visible main landmark per document.", + }), + ); + for (const element of root.querySelectorAll("button button, button a, a a, a button")) + issues.push( + createIssue({ + ruleId: "html/nested-interactive", + category: "html", + severity: "error", + title: "Interactive elements are nested", + message: "Nested buttons and links create invalid and confusing interaction behavior.", + element, + recommendation: + "Use one interactive element and style its inner non-interactive content.", + }), + ); + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/images.ts b/packages/dev-toolbar/src/rules/images.ts new file mode 100644 index 00000000..bd7f90c6 --- /dev/null +++ b/packages/dev-toolbar/src/rules/images.ts @@ -0,0 +1,98 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue, isVisible } from "./helpers.ts"; + +export const imageRules: DevToolbarRule[] = [ + { + id: "images/quality", + category: "images", + defaultSeverity: "warning", + description: "Checks image loading, sizing and optimization.", + run: ({ root, window }) => { + const issues = []; + for (const image of root.querySelectorAll("img")) { + if (image.complete && image.naturalWidth === 0) + issues.push( + createIssue({ + ruleId: "images/broken", + category: "images", + severity: "error", + title: "Image failed to load", + message: `The image at ${image.currentSrc || image.src || "(empty source)"} could not be loaded.`, + element: image, + recommendation: "Verify the URL, file path and response content type.", + }), + ); + if ( + !image.hasAttribute("width") && + !image.hasAttribute("height") && + getComputedStyle(image).aspectRatio === "auto" + ) + issues.push( + createIssue({ + ruleId: "images/dimensions", + category: "images", + severity: "warning", + title: "Image has no reserved dimensions", + message: "This image may cause layout shift while loading.", + element: image, + recommendation: "Add width and height attributes or CSS aspect-ratio.", + }), + ); + const rect = image.getBoundingClientRect(); + if (isVisible(image) && image.naturalWidth > 0 && rect.width > image.naturalWidth * 1.25) + issues.push( + createIssue({ + ruleId: "images/upscaled", + category: "images", + severity: "warning", + title: "Image is being enlarged", + message: `Rendered width ${Math.round(rect.width)}px exceeds intrinsic width ${image.naturalWidth}px.`, + element: image, + recommendation: "Use a larger source image to avoid blurring.", + }), + ); + if (image.naturalWidth > rect.width * 2.5 && rect.width > 0) + issues.push( + createIssue({ + ruleId: "images/oversized-dimensions", + category: "images", + severity: "suggestion", + title: "Image source may be oversized", + message: `Intrinsic width ${image.naturalWidth}px is much larger than rendered width ${Math.round(rect.width)}px.`, + element: image, + recommendation: "Use srcset/sizes or a smaller generated image.", + confidence: "medium", + }), + ); + if (rect.top > window.innerHeight * 1.5 && image.loading !== "lazy") + issues.push( + createIssue({ + ruleId: "images/lazy-below-fold", + category: "images", + severity: "suggestion", + title: "Below-fold image is not lazy loaded", + message: "This image starts far below the initial viewport.", + element: image, + recommendation: 'Consider loading="lazy".', + confidence: "medium", + }), + ); + if (rect.top < window.innerHeight && image.loading === "lazy") + issues.push( + createIssue({ + ruleId: "images/lazy-above-fold", + category: "images", + severity: "suggestion", + title: "Above-fold image is lazy loaded", + message: "Lazy loading a visible hero image can delay rendering.", + element: image, + recommendation: + 'Remove lazy loading and consider fetchpriority="high" for the main hero image.', + confidence: "medium", + }), + ); + } + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/index.ts b/packages/dev-toolbar/src/rules/index.ts new file mode 100644 index 00000000..2c6ad001 --- /dev/null +++ b/packages/dev-toolbar/src/rules/index.ts @@ -0,0 +1,62 @@ +import type { DevToolbarRule, DevToolbarRuleContext } from "./types.ts"; +import type { DevToolbarIssue } from "../types.ts"; +import { accessibilityRules } from "./accessibility.ts"; +import { seoRules } from "./seo.ts"; +import { imageRules } from "./images.ts"; +import { mediaRules } from "./media.ts"; +import { colorRules } from "./color.ts"; +import { htmlRules } from "./html.ts"; +import { formRules } from "./forms.ts"; +import { linkRules } from "./links.ts"; +import { performanceRules } from "./performance.ts"; +import { responsiveRules } from "./responsive.ts"; +import { securityRules } from "./security.ts"; + +export * from "./types.ts"; +export * from "./helpers.ts"; +export { + accessibilityRules, + seoRules, + imageRules, + mediaRules, + colorRules, + htmlRules, + formRules, + linkRules, + performanceRules, + responsiveRules, + securityRules, +}; + +export const DEV_TOOLBAR_RULES: DevToolbarRule[] = [ + ...accessibilityRules, + ...seoRules, + ...imageRules, + ...mediaRules, + ...colorRules, + ...htmlRules, + ...formRules, + ...linkRules, + ...performanceRules, + ...responsiveRules, + ...securityRules, +]; + +export async function runDevToolbarRules( + context: DevToolbarRuleContext, + rules = DEV_TOOLBAR_RULES, +): Promise { + const settled = await Promise.all( + rules.map(async (rule) => { + try { + return await rule.run(context); + } catch (error) { + console.warn(`[WRNexus DevToolbar] Rule ${rule.id} failed`, error); + return []; + } + }), + ); + const unique = new Map(); + for (const issue of settled.flat()) unique.set(issue.fingerprint, issue); + return [...unique.values()]; +} diff --git a/packages/dev-toolbar/src/rules/links.ts b/packages/dev-toolbar/src/rules/links.ts new file mode 100644 index 00000000..20ad5a1e --- /dev/null +++ b/packages/dev-toolbar/src/rules/links.ts @@ -0,0 +1,69 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue } from "./helpers.ts"; + +export const linkRules: DevToolbarRule[] = [ + { + id: "links/basics", + category: "links", + defaultSeverity: "warning", + description: "Checks unsafe and incomplete links.", + run: ({ root, url }) => { + const issues = []; + for (const anchor of root.querySelectorAll("a")) { + const raw = anchor.getAttribute("href"); + if (!raw || raw === "#") + issues.push( + createIssue({ + ruleId: "links/empty", + category: "links", + severity: "warning", + title: "Link has no useful destination", + message: `The href is ${raw ? '"#"' : "missing"}.`, + element: anchor, + recommendation: "Provide a real URL or use a button for an action.", + }), + ); + if (raw?.trim().toLowerCase().startsWith("javascript:")) + issues.push( + createIssue({ + ruleId: "links/javascript-url", + category: "security", + severity: "error", + title: "JavaScript URL used", + message: "javascript: links are unsafe and inaccessible.", + element: anchor, + recommendation: "Use a button and a normal event handler.", + }), + ); + if ( + anchor.target === "_blank" && + !anchor.rel.split(/\s+/).some((value) => value === "noopener" || value === "noreferrer") + ) + issues.push( + createIssue({ + ruleId: "links/blank-rel", + category: "security", + severity: "warning", + title: "New-tab link lacks rel protection", + message: "The opened page may retain access to window.opener.", + element: anchor, + recommendation: 'Add rel="noopener noreferrer".', + }), + ); + if (raw?.startsWith("http://") && url.protocol === "https:") + issues.push( + createIssue({ + ruleId: "links/mixed-content", + category: "security", + severity: "error", + title: "Insecure link on HTTPS page", + message: "This link uses HTTP from an HTTPS page.", + element: anchor, + recommendation: "Use an HTTPS destination.", + }), + ); + } + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/media.ts b/packages/dev-toolbar/src/rules/media.ts new file mode 100644 index 00000000..1fbe9239 --- /dev/null +++ b/packages/dev-toolbar/src/rules/media.ts @@ -0,0 +1,78 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue } from "./helpers.ts"; + +export const mediaRules: DevToolbarRule[] = [ + { + id: "media/basics", + category: "media", + defaultSeverity: "warning", + description: "Checks video, audio and iframe accessibility.", + run: ({ root }) => { + const issues = []; + for (const video of root.querySelectorAll("video")) { + if (video.autoplay && !video.muted) + issues.push( + createIssue({ + ruleId: "media/autoplay-sound", + category: "media", + severity: "error", + title: "Autoplay video is not muted", + message: "Autoplaying sound is disruptive and commonly blocked by browsers.", + element: video, + recommendation: "Remove autoplay or add muted.", + }), + ); + if (!video.controls && !video.autoplay) + issues.push( + createIssue({ + ruleId: "media/no-controls", + category: "media", + severity: "warning", + title: "Video has no controls", + message: "Users may be unable to play, pause or seek.", + element: video, + recommendation: "Add controls or provide equivalent custom controls.", + }), + ); + if (!video.poster) + issues.push( + createIssue({ + ruleId: "media/no-poster", + category: "media", + severity: "suggestion", + title: "Video has no poster", + message: "A poster can improve perceived loading and visual quality.", + element: video, + recommendation: "Provide an optimized poster image.", + confidence: "medium", + }), + ); + if (!video.querySelector('track[kind="captions"]')) + issues.push( + createIssue({ + ruleId: "media/no-captions", + category: "media", + severity: "warning", + title: "Video has no captions track", + message: "Spoken content may be inaccessible.", + element: video, + recommendation: "Add a captions track when the video contains speech.", + }), + ); + } + for (const iframe of root.querySelectorAll("iframe:not([title])")) + issues.push( + createIssue({ + ruleId: "media/iframe-title", + category: "media", + severity: "error", + title: "Iframe is missing a title", + message: "Assistive technology cannot identify the embedded content.", + element: iframe, + recommendation: "Add a concise title attribute.", + }), + ); + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/performance.ts b/packages/dev-toolbar/src/rules/performance.ts new file mode 100644 index 00000000..57dadeea --- /dev/null +++ b/packages/dev-toolbar/src/rules/performance.ts @@ -0,0 +1,55 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue } from "./helpers.ts"; + +export const performanceRules: DevToolbarRule[] = [ + { + id: "performance/resources", + category: "performance", + defaultSeverity: "warning", + description: "Checks resource count and transfer sizes.", + run: ({ performanceEntries }) => { + const resources = performanceEntries.filter( + (entry): entry is PerformanceResourceTiming => entry.entryType === "resource", + ); + const issues = []; + if (resources.length > 150) + issues.push( + createIssue({ + ruleId: "performance/resource-count", + category: "performance", + severity: "warning", + title: "Page loads many resources", + message: `Found ${resources.length} resource requests.`, + recommendation: + "Remove duplicates, combine tiny assets where useful, and load non-critical resources later.", + }), + ); + const total = resources.reduce((sum, entry) => sum + (entry.transferSize || 0), 0); + if (total > 5_000_000) + issues.push( + createIssue({ + ruleId: "performance/transfer-size", + category: "performance", + severity: total > 10_000_000 ? "error" : "warning", + title: "Page transfer size is large", + message: `Observed transfer size is approximately ${(total / 1_000_000).toFixed(2)} MB.`, + recommendation: + "Compress images, scripts, styles and fonts; review third-party resources.", + }), + ); + for (const entry of resources.filter((item) => item.duration > 2000).slice(0, 20)) + issues.push( + createIssue({ + ruleId: "performance/slow-resource", + category: "network", + severity: "warning", + title: "Resource loaded slowly", + message: `${entry.name} took ${Math.round(entry.duration)} ms.`, + recommendation: "Inspect server timing, caching and payload size.", + metadata: { url: entry.name, duration: entry.duration }, + }), + ); + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/responsive.ts b/packages/dev-toolbar/src/rules/responsive.ts new file mode 100644 index 00000000..53e90872 --- /dev/null +++ b/packages/dev-toolbar/src/rules/responsive.ts @@ -0,0 +1,45 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue, isVisible } from "./helpers.ts"; + +export const responsiveRules: DevToolbarRule[] = [ + { + id: "responsive/overflow", + category: "responsive", + defaultSeverity: "warning", + description: "Checks horizontal overflow and viewport escape.", + run: ({ document, root, window }) => { + const issues = []; + if (document.documentElement.scrollWidth > window.innerWidth + 2) + issues.push( + createIssue({ + ruleId: "responsive/document-overflow", + category: "responsive", + severity: "error", + title: "Page has horizontal overflow", + message: `Document width ${document.documentElement.scrollWidth}px exceeds viewport ${window.innerWidth}px.`, + recommendation: "Inspect fixed widths, transforms, long text and overflowing media.", + }), + ); + for (const element of root.querySelectorAll("body *")) { + if (!isVisible(element)) continue; + const rect = element.getBoundingClientRect(); + if (rect.right > window.innerWidth + 8 || rect.left < -8) { + issues.push( + createIssue({ + ruleId: "responsive/element-overflow", + category: "responsive", + severity: "warning", + title: "Element extends outside the viewport", + message: `Element bounds are ${Math.round(rect.left)}px to ${Math.round(rect.right)}px in a ${window.innerWidth}px viewport.`, + element, + recommendation: + "Use fluid sizing, wrapping, max-width, or an intentional scroll container.", + }), + ); + if (issues.length >= 20) break; + } + } + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/security.ts b/packages/dev-toolbar/src/rules/security.ts new file mode 100644 index 00000000..25d167c1 --- /dev/null +++ b/packages/dev-toolbar/src/rules/security.ts @@ -0,0 +1,40 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue } from "./helpers.ts"; + +export const securityRules: DevToolbarRule[] = [ + { + id: "security/page", + category: "security", + defaultSeverity: "warning", + description: "Checks development-visible security mistakes.", + run: ({ url, root }) => { + const issues = []; + for (const [key] of url.searchParams) + if (/pass(word)?|token|secret|api[-_]?key/i.test(key)) + issues.push( + createIssue({ + ruleId: "security/secret-query", + category: "security", + severity: "error", + title: "Potential secret appears in URL", + message: `The query parameter “${key}” may contain sensitive information.`, + recommendation: + "Send secrets in a secure request body or authorization header, not a URL.", + }), + ); + for (const form of root.querySelectorAll('form[action^="http://"]')) + issues.push( + createIssue({ + ruleId: "security/insecure-form", + category: "security", + severity: "error", + title: "Form submits over HTTP", + message: "Form values may be transmitted without transport encryption.", + element: form, + recommendation: "Submit to an HTTPS endpoint.", + }), + ); + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/seo.ts b/packages/dev-toolbar/src/rules/seo.ts new file mode 100644 index 00000000..d166afdd --- /dev/null +++ b/packages/dev-toolbar/src/rules/seo.ts @@ -0,0 +1,113 @@ +import type { DevToolbarRule } from "./types.ts"; +import { createIssue } from "./helpers.ts"; + +export const seoRules: DevToolbarRule[] = [ + { + id: "seo/document", + category: "seo", + defaultSeverity: "warning", + description: "Validates core document metadata.", + run: ({ document }) => { + const issues = []; + const title = document.title.trim(); + if (!title) + issues.push( + createIssue({ + ruleId: "seo/title-missing", + category: "seo", + severity: "error", + title: "Page title is missing", + message: "The document does not have a useful title.", + recommendation: "Add a unique title describing this page.", + }), + ); + else if (title.length < 20 || title.length > 65) + issues.push( + createIssue({ + ruleId: "seo/title-length", + category: "seo", + severity: "warning", + title: "Page title length may be suboptimal", + message: `The title contains ${title.length} characters.`, + recommendation: "Keep most titles between about 20 and 65 characters.", + confidence: "medium", + }), + ); + const description = document + .querySelector('meta[name="description"]') + ?.content.trim(); + if (!description) + issues.push( + createIssue({ + ruleId: "seo/description-missing", + category: "seo", + severity: "warning", + title: "Meta description is missing", + message: "Search and social previews may not have a useful description.", + recommendation: "Add a unique meta description for the page.", + }), + ); + else if (description.length < 70 || description.length > 170) + issues.push( + createIssue({ + ruleId: "seo/description-length", + category: "seo", + severity: "suggestion", + title: "Meta description length may be suboptimal", + message: `The description contains ${description.length} characters.`, + recommendation: "Aim for a concise description of roughly 70–170 characters.", + confidence: "medium", + }), + ); + if (!document.querySelector('meta[name="viewport"]')) + issues.push( + createIssue({ + ruleId: "seo/viewport-missing", + category: "seo", + severity: "error", + title: "Viewport meta tag is missing", + message: "The page may render incorrectly on mobile devices.", + recommendation: "Add width=device-width, initial-scale=1.", + }), + ); + if (!document.querySelector('link[rel="canonical"]')) + issues.push( + createIssue({ + ruleId: "seo/canonical-missing", + category: "seo", + severity: "suggestion", + title: "Canonical URL is missing", + message: "Search engines have no explicit preferred URL for this page.", + recommendation: "Add a canonical link for public indexable pages.", + confidence: "medium", + }), + ); + const h1s = document.querySelectorAll("h1"); + if (h1s.length === 0) + issues.push( + createIssue({ + ruleId: "seo/h1-missing", + category: "seo", + severity: "warning", + title: "Page has no h1 heading", + message: "The page lacks a clear primary heading.", + recommendation: "Add one descriptive h1.", + }), + ); + if (h1s.length > 1) + issues.push( + createIssue({ + ruleId: "seo/multiple-h1", + category: "seo", + severity: "suggestion", + title: "Page has multiple h1 headings", + message: `Found ${h1s.length} h1 elements.`, + recommendation: + "Use one clear primary page heading unless multiple h1 elements are intentional.", + confidence: "medium", + }), + ); + return issues; + }, + }, +]; diff --git a/packages/dev-toolbar/src/rules/types.ts b/packages/dev-toolbar/src/rules/types.ts new file mode 100644 index 00000000..2ef2802e --- /dev/null +++ b/packages/dev-toolbar/src/rules/types.ts @@ -0,0 +1,18 @@ +import type { DevToolbarCategory, DevToolbarIssue, DevToolbarSeverity } from "../types.ts"; + +export interface DevToolbarRuleContext { + document: Document; + window: Window; + url: URL; + root: ParentNode; + performanceEntries: PerformanceEntry[]; + config?: Record; +} + +export interface DevToolbarRule { + id: string; + category: DevToolbarCategory; + defaultSeverity: DevToolbarSeverity; + description: string; + run(context: DevToolbarRuleContext): DevToolbarIssue[] | Promise; +} diff --git a/packages/dev-toolbar/src/server/collector.ts b/packages/dev-toolbar/src/server/collector.ts new file mode 100644 index 00000000..2baa6fce --- /dev/null +++ b/packages/dev-toolbar/src/server/collector.ts @@ -0,0 +1,52 @@ +import type { DevToolbarIssue } from "../types.ts"; + +export type DevToolbarIssueListener = (issues: DevToolbarIssue[]) => void; + +export interface DevToolbarCollector { + add(issue: DevToolbarIssue): void; + addMany(issues: DevToolbarIssue[]): void; + clear(scope?: string): void; + getIssues(pathname?: string): DevToolbarIssue[]; + subscribe(listener: DevToolbarIssueListener): () => void; +} + +export function createDevToolbarCollector(): DevToolbarCollector { + const issues = new Map(); + const listeners = new Set(); + const emit = () => { + const snapshot = [...issues.values()].sort((a, b) => b.createdAt - a.createdAt); + for (const listener of listeners) listener(snapshot); + }; + return { + add(issue) { + issues.set(issue.fingerprint, issue); + emit(); + }, + addMany(next) { + for (const issue of next) issues.set(issue.fingerprint, issue); + emit(); + }, + clear(scope) { + if (!scope) issues.clear(); + else + for (const [key, issue] of issues) { + const pathname = + typeof issue.metadata?.pathname === "string" ? issue.metadata.pathname : undefined; + if (pathname === scope || issue.source?.file === scope || issue.category === scope) + issues.delete(key); + } + emit(); + }, + getIssues(pathname) { + const values = [...issues.values()]; + if (!pathname) return values; + return values.filter( + (issue) => !issue.metadata?.pathname || issue.metadata.pathname === pathname, + ); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/packages/dev-toolbar/src/server/editor.ts b/packages/dev-toolbar/src/server/editor.ts new file mode 100644 index 00000000..d2261d48 --- /dev/null +++ b/packages/dev-toolbar/src/server/editor.ts @@ -0,0 +1,61 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +export interface OpenEditorRequest { + file: string; + line?: number; + column?: number; +} + +export interface OpenEditorOptions { + root: string; + editor?: string; + spawn?: (command: string[], options?: { cwd?: string }) => unknown; +} + +const EDITORS: Record string[]> = { + code: (file, line, column) => ["code", "--goto", `${file}:${line}:${column}`], + "code-insiders": (file, line, column) => ["code-insiders", "--goto", `${file}:${line}:${column}`], + cursor: (file, line, column) => ["cursor", "--goto", `${file}:${line}:${column}`], + windsurf: (file, line, column) => ["windsurf", "--goto", `${file}:${line}:${column}`], + zed: (file, line, column) => ["zed", `${file}:${line}:${column}`], + sublime: (file, line, column) => ["subl", `${file}:${line}:${column}`], + webstorm: (file, line) => ["webstorm", "--line", String(line), file], + idea: (file, line) => ["idea", "--line", String(line), file], +}; + +export function resolveEditorFile(root: string, file: string): string { + if (!file || file.includes("\0") || /^https?:\/\//i.test(file)) + throw new Error("Invalid source file path."); + const resolvedRoot = resolve(root); + const resolvedFile = isAbsolute(file) ? resolve(file) : resolve(resolvedRoot, file); + const rel = relative(resolvedRoot, resolvedFile); + if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) return resolvedFile; + throw new Error("Source file is outside the configured project root."); +} + +export function buildEditorCommand( + request: OpenEditorRequest, + options: OpenEditorOptions, +): string[] { + const file = resolveEditorFile(options.root, request.file); + const line = Math.max(1, Math.floor(request.line ?? 1)); + const column = Math.max(1, Math.floor(request.column ?? 1)); + const editor = + options.editor ?? + process.env.WRNEXUS_EDITOR ?? + process.env.VISUAL ?? + process.env.EDITOR ?? + "code"; + const factory = EDITORS[editor]; + if (!factory) throw new Error(`Unsupported editor: ${editor}`); + return factory(file, line, column); +} + +export function openInEditor(request: OpenEditorRequest, options: OpenEditorOptions): void { + const command = buildEditorCommand(request, options); + const spawn = + options.spawn ?? + ((args: string[], spawnOptions?: { cwd?: string }) => + Bun.spawn(args, { cwd: spawnOptions?.cwd, stdout: "ignore", stderr: "ignore" })); + spawn(command, { cwd: resolve(options.root) }); +} diff --git a/packages/dev-toolbar/src/server/index.ts b/packages/dev-toolbar/src/server/index.ts new file mode 100644 index 00000000..ec13a673 --- /dev/null +++ b/packages/dev-toolbar/src/server/index.ts @@ -0,0 +1,6 @@ +export * from "./collector.ts"; +export * from "./registry.ts"; +export * from "./serialize.ts"; +export * from "./issues.ts"; +export * from "./editor.ts"; +export * from "./routes.ts"; diff --git a/packages/dev-toolbar/src/server/issues.ts b/packages/dev-toolbar/src/server/issues.ts new file mode 100644 index 00000000..8e88e4b6 --- /dev/null +++ b/packages/dev-toolbar/src/server/issues.ts @@ -0,0 +1,60 @@ +import type { + DevToolbarCategory, + DevToolbarIssue, + DevToolbarSeverity, + DevToolbarSourceLocation, +} from "../types.ts"; + +export function createServerIssue(input: { + ruleId: string; + category: DevToolbarCategory; + severity: DevToolbarSeverity; + title: string; + message: string; + pathname?: string; + source?: DevToolbarSourceLocation; + stack?: string; + recommendation?: string; +}): DevToolbarIssue { + const sourceKey = input.source?.file + ? `${input.source.file}:${input.source.line ?? 0}:${input.source.column ?? 0}` + : ""; + return { + id: `wrn-server-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ruleId: input.ruleId, + category: input.category, + severity: input.severity, + title: input.title, + message: input.message, + source: input.source, + recommendation: input.recommendation, + fingerprint: [input.ruleId, input.pathname ?? "", sourceKey, input.message].join("|"), + createdAt: Date.now(), + confidence: "high", + metadata: { pathname: input.pathname, stack: input.stack }, + }; +} + +export function issueFromError( + error: unknown, + input: { + ruleId?: string; + category?: DevToolbarCategory; + title?: string; + pathname?: string; + source?: DevToolbarSourceLocation; + } = {}, +): DevToolbarIssue { + const normalized = error instanceof Error ? error : new Error(String(error)); + return createServerIssue({ + ruleId: input.ruleId ?? "server/unhandled-error", + category: input.category ?? "server", + severity: "error", + title: input.title ?? normalized.name ?? "Server error", + message: normalized.message, + pathname: input.pathname, + source: input.source, + stack: normalized.stack, + recommendation: "Inspect the stack trace and open the referenced source file.", + }); +} diff --git a/packages/dev-toolbar/src/server/registry.ts b/packages/dev-toolbar/src/server/registry.ts new file mode 100644 index 00000000..223e6b14 --- /dev/null +++ b/packages/dev-toolbar/src/server/registry.ts @@ -0,0 +1,28 @@ +export interface DevToolbarApp { + id: string; + name: string; + icon?: string; + entrypoint?: string; + order?: number; +} + +export interface DevToolbarRegistry { + registerApp(app: DevToolbarApp): void; + unregisterApp(id: string): void; + getApps(): DevToolbarApp[]; +} + +export function createDevToolbarRegistry(): DevToolbarRegistry { + const apps = new Map(); + return { + registerApp(app) { + apps.set(app.id, app); + }, + unregisterApp(id) { + apps.delete(id); + }, + getApps() { + return [...apps.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); + }, + }; +} diff --git a/packages/dev-toolbar/src/server/routes.ts b/packages/dev-toolbar/src/server/routes.ts new file mode 100644 index 00000000..e89c9a6c --- /dev/null +++ b/packages/dev-toolbar/src/server/routes.ts @@ -0,0 +1,59 @@ +import type { DevToolbarCollector } from "./collector.ts"; +import { DEV_TOOLBAR_CSS, DEV_TOOLBAR_RUNTIME } from "../client/index.ts"; +import { openInEditor } from "./editor.ts"; +import { serializeDevToolbarJson } from "./serialize.ts"; + +export interface DevToolbarRouteOptions { + mode: string; + root: string; + collector: DevToolbarCollector; + editor?: string; + allowOpenEditor?: boolean; +} + +const json = (value: unknown, status = 200) => + new Response(serializeDevToolbarJson(value), { + status, + headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }, + }); + +export async function handleDevToolbarRoute( + request: Request, + options: DevToolbarRouteOptions, +): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith("/__wrnexus/dev-toolbar")) return null; + if (options.mode !== "development") return new Response("Not found", { status: 404 }); + if (url.pathname === "/__wrnexus/dev-toolbar.js" && request.method === "GET") + return new Response(DEV_TOOLBAR_RUNTIME, { + headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }, + }); + if (url.pathname === "/__wrnexus/dev-toolbar.css" && request.method === "GET") + return new Response(DEV_TOOLBAR_CSS, { + headers: { "content-type": "text/css; charset=utf-8", "cache-control": "no-store" }, + }); + if (url.pathname === "/__wrnexus/dev-toolbar/issues" && request.method === "GET") + return json({ + issues: options.collector.getIssues(url.searchParams.get("pathname") ?? undefined), + }); + if (url.pathname === "/__wrnexus/dev-toolbar/open-editor" && request.method === "POST") { + if (options.allowOpenEditor === false) + return json({ error: "Open in editor is disabled." }, 403); + const body = (await request.json().catch(() => null)) as { + file?: string; + line?: number; + column?: number; + } | null; + if (!body?.file) return json({ error: "file is required" }, 400); + try { + openInEditor( + { file: body.file, line: body.line, column: body.column }, + { root: options.root, editor: options.editor }, + ); + return json({ ok: true }); + } catch (error) { + return json({ error: error instanceof Error ? error.message : String(error) }, 400); + } + } + return new Response("Not found", { status: 404 }); +} diff --git a/packages/dev-toolbar/src/server/serialize.ts b/packages/dev-toolbar/src/server/serialize.ts new file mode 100644 index 00000000..b0600b35 --- /dev/null +++ b/packages/dev-toolbar/src/server/serialize.ts @@ -0,0 +1,8 @@ +export function serializeDevToolbarJson(value: unknown): string { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} diff --git a/packages/dev-toolbar/src/types.ts b/packages/dev-toolbar/src/types.ts new file mode 100644 index 00000000..3bce4fc9 --- /dev/null +++ b/packages/dev-toolbar/src/types.ts @@ -0,0 +1,134 @@ +export type DevToolbarSeverity = "error" | "warning" | "info" | "suggestion"; + +export type DevToolbarCategory = + | "runtime" + | "compiler" + | "server" + | "routing" + | "accessibility" + | "seo" + | "performance" + | "images" + | "media" + | "color" + | "forms" + | "links" + | "html" + | "css" + | "javascript" + | "security" + | "network" + | "responsive" + | "best-practice"; + +export interface DevToolbarSourceLocation { + file?: string; + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + component?: string; + page?: string; +} + +export interface DevToolbarElementTarget { + selector?: string; + tagName?: string; + id?: string; + classes?: string[]; + text?: string; + sourceId?: string; +} + +export interface DevToolbarFix { + title: string; + description?: string; + replacement?: string; + automatic?: boolean; +} + +export interface DevToolbarIssue { + id: string; + ruleId: string; + category: DevToolbarCategory; + severity: DevToolbarSeverity; + title: string; + message: string; + explanation?: string; + recommendation?: string; + documentation?: string; + target?: DevToolbarElementTarget; + source?: DevToolbarSourceLocation; + fix?: DevToolbarFix; + fingerprint: string; + createdAt: number; + confidence?: "high" | "medium" | "low"; + metadata?: Record; +} + +export interface DevToolbarMetrics { + domNodes: number; + htmlBytes?: number; + cssResources: number; + scriptResources: number; + imageResources: number; + totalTransferBytes?: number; + largestImageBytes?: number; + renderBlockingResources?: number; + longTasks?: number; + layoutShifts?: number; + pageLoadMs?: number; +} + +export interface DevToolbarPageReport { + url: string; + pathname: string; + title: string; + status: number; + generatedAt: number; + issues: DevToolbarIssue[]; + metrics: DevToolbarMetrics; +} + +export interface DevToolbarConfig { + enabled?: boolean; + position?: "bottom-center" | "bottom-left" | "bottom-right"; + defaultOpen?: boolean; + keyboardShortcut?: string; + scanOnNavigation?: boolean; + scanOnHmr?: boolean; + openEditor?: boolean; + editor?: string; + rules?: Partial>; + severity?: Partial>; + ignoredRules?: string[]; + ignoredPaths?: string[]; + slowRequestMs?: number; + largeImageBytes?: number; + veryLargeImageBytes?: number; +} + +export type DevToolbarServerMessage = + | { type: "toolbar:issues"; issues: DevToolbarIssue[] } + | { type: "toolbar:clear"; scope?: string } + | { type: "toolbar:scan"; reason?: string } + | { type: "toolbar:route"; pathname: string; status: number } + | { type: "toolbar:compiler-error"; issue: DevToolbarIssue } + | { type: "toolbar:runtime-error"; issue: DevToolbarIssue }; + +export interface DevToolbarClientApi { + open(): void; + close(): void; + toggle(): void; + scan(): Promise; + clear(): void; + report(): DevToolbarPageReport | null; + highlight(selector: string): void; + configure(config: DevToolbarConfig): void; +} + +declare global { + interface Window { + __wrnexusDevToolbar?: DevToolbarClientApi; + } +} diff --git a/packages/dev-toolbar/test/client/assets.test.ts b/packages/dev-toolbar/test/client/assets.test.ts new file mode 100644 index 00000000..76fd8ce7 --- /dev/null +++ b/packages/dev-toolbar/test/client/assets.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test"; +import { DEV_TOOLBAR_CSS, DEV_TOOLBAR_RUNTIME } from "../../src/client/index.ts"; + +test("exports usable development assets", () => { + expect(DEV_TOOLBAR_RUNTIME).toContain("__wrnexusDevToolbar"); + expect(DEV_TOOLBAR_RUNTIME).toContain("wrnexus:navigated"); + expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel"); +}); diff --git a/packages/dev-toolbar/test/server/collector.test.ts b/packages/dev-toolbar/test/server/collector.test.ts new file mode 100644 index 00000000..4253daf2 --- /dev/null +++ b/packages/dev-toolbar/test/server/collector.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { createDevToolbarCollector } from "../../src/server/collector.ts"; +import { createServerIssue } from "../../src/server/issues.ts"; + +describe("dev toolbar collector", () => { + test("deduplicates by fingerprint", () => { + const collector = createDevToolbarCollector(); + const issue = createServerIssue({ + ruleId: "route/not-found", + category: "routing", + severity: "warning", + title: "Not found", + message: "Missing page", + pathname: "/missing", + }); + collector.add(issue); + collector.add({ ...issue, id: "new-id" }); + expect(collector.getIssues()).toHaveLength(1); + }); + + test("clears issues by pathname", () => { + const collector = createDevToolbarCollector(); + collector.add( + createServerIssue({ + ruleId: "route/not-found", + category: "routing", + severity: "warning", + title: "Not found", + message: "A", + pathname: "/a", + }), + ); + collector.add( + createServerIssue({ + ruleId: "route/not-found", + category: "routing", + severity: "warning", + title: "Not found", + message: "B", + pathname: "/b", + }), + ); + collector.clear("/a"); + expect(collector.getIssues()).toHaveLength(1); + }); +}); diff --git a/packages/dev-toolbar/test/server/editor.test.ts b/packages/dev-toolbar/test/server/editor.test.ts new file mode 100644 index 00000000..457dcf04 --- /dev/null +++ b/packages/dev-toolbar/test/server/editor.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { buildEditorCommand, resolveEditorFile } from "../../src/server/editor.ts"; + +describe("open editor security", () => { + const projectRoot = resolve("project"); + const pageFile = resolve(projectRoot, "app/pages/index.wrn"); + + test("resolves project files", () => { + expect(resolveEditorFile(projectRoot, "app/pages/index.wrn")).toBe(pageFile); + }); + + test("rejects traversal outside root", () => { + expect(() => resolveEditorFile(projectRoot, "../secret.txt")).toThrow( + "Source file is outside the configured project root.", + ); + }); + + test("builds argument array without shell", () => { + expect( + buildEditorCommand( + { + file: "app/pages/index.wrn", + line: 8, + column: 2, + }, + { + root: projectRoot, + editor: "code", + }, + ), + ).toEqual(["code", "--goto", `${pageFile}:8:2`]); + }); + + test("rejects null bytes", () => { + expect(() => resolveEditorFile(projectRoot, "app/pages/index.wrn\0.txt")).toThrow( + "Invalid source file path.", + ); + }); + + test("rejects remote URLs", () => { + expect(() => resolveEditorFile(projectRoot, "https://example.com/file.wrn")).toThrow( + "Invalid source file path.", + ); + }); + + test("normalizes invalid line and column values", () => { + expect( + buildEditorCommand( + { + file: "app/pages/index.wrn", + line: -10, + column: 0, + }, + { + root: projectRoot, + editor: "code", + }, + ), + ).toEqual(["code", "--goto", `${pageFile}:1:1`]); + }); +}); diff --git a/packages/dev-toolbar/test/server/serialize.test.ts b/packages/dev-toolbar/test/server/serialize.test.ts new file mode 100644 index 00000000..2f68e87b --- /dev/null +++ b/packages/dev-toolbar/test/server/serialize.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test"; +import { serializeDevToolbarJson } from "../../src/server/serialize.ts"; + +test("escapes script-sensitive characters", () => { + const value = serializeDevToolbarJson({ value: "&" }); + expect(value).not.toContain(""); + expect(value).toContain("\\u003c"); +}); diff --git a/packages/dev-toolbar/tsconfig.json b/packages/dev-toolbar/tsconfig.json new file mode 100644 index 00000000..6c155e1d --- /dev/null +++ b/packages/dev-toolbar/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/encryption/package.json b/packages/encryption/package.json index c97420f3..6519fbbb 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index aba8d081..62aede38 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 762061b0..8d088932 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 401fe5a0..c1f00d66 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 651d6e9a..f9c4a691 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 4b7f0dfc..59dc2ad2 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index c96f1b0a..25bf849f 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 70b4506b..2a68a21c 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index d00be1f8..69f230a8 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index a180691f..545f2c8f 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index 6497c1d4..74d1171f 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 61af0d10..7baa8d01 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index 44520ead..4a753dc7 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.74", + "version": "0.2.75", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/config.ts b/packages/styles/src/config.ts index 9ddf32d5..0577de42 100644 --- a/packages/styles/src/config.ts +++ b/packages/styles/src/config.ts @@ -111,7 +111,29 @@ export interface PwaConfig { cacheName?: string; } +export type DevToolbarPosition = "bottom-center" | "bottom-left" | "bottom-right"; + +export interface DevToolbarConfig { + enabled?: boolean; + position?: DevToolbarPosition; + defaultOpen?: boolean; + keyboardShortcut?: string; + scanOnNavigation?: boolean; + scanOnHmr?: boolean; + openEditor?: boolean; + editor?: string; + rules?: Partial>; + severity?: Partial>; + ignoredRules?: string[]; + ignoredPaths?: string[]; + slowRequestMs?: number; + largeImageBytes?: number; + veryLargeImageBytes?: number; +} + export interface AppConfig { + /** Development-only page diagnostics toolbar. Enabled by default in development. */ + devToolbar?: boolean | DevToolbarConfig; /** Raw HTML appended to every page's `` (e.g. CDN stylesheet links). */ head?: string | string[]; /** Global SEO defaults merged with every page's exported `meta`. */ diff --git a/packages/test/package.json b/packages/test/package.json index a6a70a53..79bc7cf3 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index c5bbb1e1..f069be72 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index d0c0291f..04e17213 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index ab5dc3cb..c55d9882 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index a6fedc91..95d584d0 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.74", + "version": "0.2.75", "private": true, "type": "module", "main": "src/index.ts", diff --git a/tsconfig.json b/tsconfig.json index aea584d1..dcf0ede8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,12 @@ "@wrnexus/mobile": ["./packages/mobile/src/index.ts"], "@wrnexus/native": ["./packages/native/src/index.ts"], "@wrnexus/native/*": ["./packages/native/src/*.ts"], - "@wrnexus/test": ["./packages/test/src/index.ts"] + "@wrnexus/test": ["./packages/test/src/index.ts"], + "@wrnexus/dev-toolbar": ["./packages/dev-toolbar/src/index.ts"], + "@wrnexus/dev-toolbar/client": ["./packages/dev-toolbar/src/client/index.ts"], + "@wrnexus/dev-toolbar/server": ["./packages/dev-toolbar/src/server/index.ts"], + "@wrnexus/dev-toolbar/types": ["./packages/dev-toolbar/src/types.ts"], + "@wrnexus/dev-toolbar/rules": ["./packages/dev-toolbar/src/rules/index.ts"] } }, "exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"] diff --git a/update-package-versions.mjs b/update-package-versions.mjs index 13e7126e..2357abf6 100644 --- a/update-package-versions.mjs +++ b/update-package-versions.mjs @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const version = "0.2.74"; +const version = "0.2.75"; const dependencyGroups = [ "dependencies",