release: WRNexusJS 0.2.75

This commit is contained in:
2026-07-21 12:30:46 +05:30
parent 2ca2d02b22
commit 69b6cd431f
100 changed files with 2313 additions and 116 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.", "description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+11 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.", "description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js" "wrnexus": "./dist/index.js"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74", "@wrnexus/core": "^0.2.75",
"@wrnexus/router": "^0.2.74", "@wrnexus/router": "^0.2.75",
"@wrnexus/csr": "^0.2.74", "@wrnexus/csr": "^0.2.75",
"@wrnexus/compiler": "^0.2.74", "@wrnexus/compiler": "^0.2.75",
"@wrnexus/styles": "^0.2.74", "@wrnexus/styles": "^0.2.75",
"@wrnexus/dev-server": "^0.2.74", "@wrnexus/dev-server": "^0.2.75",
"@wrnexus/ui": "^0.2.74", "@wrnexus/ui": "^0.2.75",
"@wrnexus/validation": "^0.2.74", "@wrnexus/validation": "^0.2.75",
"@wrnexus/i18n": "^0.2.74", "@wrnexus/i18n": "^0.2.75",
"@wrnexus/db": "^0.2.74" "@wrnexus/db": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.", "description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.", "description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.", "description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74" "@wrnexus/core": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.", "description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+14 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.", "description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -25,18 +25,19 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74", "@wrnexus/core": "^0.2.75",
"@wrnexus/router": "^0.2.74", "@wrnexus/dev-toolbar": "^0.2.75",
"@wrnexus/ssr": "^0.2.74", "@wrnexus/router": "^0.2.75",
"@wrnexus/csr": "^0.2.74", "@wrnexus/ssr": "^0.2.75",
"@wrnexus/compiler": "^0.2.74", "@wrnexus/csr": "^0.2.75",
"@wrnexus/styles": "^0.2.74", "@wrnexus/compiler": "^0.2.75",
"@wrnexus/ui": "^0.2.74", "@wrnexus/styles": "^0.2.75",
"@wrnexus/validation": "^0.2.74", "@wrnexus/ui": "^0.2.75",
"@wrnexus/i18n": "^0.2.74", "@wrnexus/validation": "^0.2.75",
"@wrnexus/db": "^0.2.74", "@wrnexus/i18n": "^0.2.75",
"@wrnexus/pubsub": "^0.2.74", "@wrnexus/db": "^0.2.75",
"@wrnexus/uploader": "^0.2.74" "@wrnexus/pubsub": "^0.2.75",
"@wrnexus/uploader": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+23
View File
@@ -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 `</body>` in development:
```html
<script type="module" src="/__wrnexus/dev-toolbar.js" data-wrnexus-dev-toolbar></script>
```
The browser runtime exposes `window.__wrnexusDevToolbar`.
+42
View File
@@ -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"
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.", "description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74" "@wrnexus/core": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.", "description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74" "@wrnexus/core": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.", "description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.", "description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/native": "^0.2.74" "@wrnexus/native": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.", "description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.", "description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.", "description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.", "description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.", "description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.", "description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,8 +21,8 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/compiler": "^0.2.74", "@wrnexus/compiler": "^0.2.75",
"@wrnexus/core": "^0.2.74" "@wrnexus/core": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.", "description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74" "@wrnexus/core": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.", "description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/uploader": "^0.2.74" "@wrnexus/uploader": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.", "description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.", "description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.", "description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -25,7 +25,7 @@
"./ui.css": "./ui.css" "./ui.css": "./ui.css"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74" "@wrnexus/core": "^0.2.75"
}, },
"files": [ "files": [
"dist", "dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.", "description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.74" "@wrnexus/core": "^0.2.75"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.", "description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+9
View File
@@ -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
+81
View File
@@ -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`.
+37 -26
View File
@@ -35,15 +35,15 @@
}, },
"packages/ai": { "packages/ai": {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/authz": { "packages/authz": {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/cli": { "packages/cli": {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.2.17", "version": "0.2.74",
"bin": { "bin": {
"wrnexus": "src/index.ts", "wrnexus": "src/index.ts",
}, },
@@ -62,31 +62,32 @@
}, },
"packages/compiler": { "packages/compiler": {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/core": { "packages/core": {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/csr": { "packages/csr": {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/db": { "packages/db": {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/dev-server": { "packages/dev-server": {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/compiler": "workspace:*", "@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*", "@wrnexus/csr": "workspace:*",
"@wrnexus/db": "workspace:*", "@wrnexus/db": "workspace:*",
"@wrnexus/dev-toolbar": "workspace:*",
"@wrnexus/i18n": "workspace:*", "@wrnexus/i18n": "workspace:*",
"@wrnexus/pubsub": "workspace:*", "@wrnexus/pubsub": "workspace:*",
"@wrnexus/router": "workspace:*", "@wrnexus/router": "workspace:*",
@@ -97,58 +98,66 @@
"@wrnexus/validation": "workspace:*", "@wrnexus/validation": "workspace:*",
}, },
}, },
"packages/dev-toolbar": {
"name": "@wrnexus/dev-toolbar",
"version": "0.2.58",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2",
},
},
"packages/encryption": { "packages/encryption": {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/helpers": { "packages/helpers": {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/i18n": { "packages/i18n": {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/jwt": { "packages/jwt": {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/mobile": { "packages/mobile": {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/native": "workspace:*", "@wrnexus/native": "workspace:*",
}, },
}, },
"packages/native": { "packages/native": {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/oauth": { "packages/oauth": {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/pubsub": { "packages/pubsub": {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/queue": { "packages/queue": {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/reactive": { "packages/reactive": {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/router": { "packages/router": {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/compiler": "workspace:*", "@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
@@ -156,43 +165,43 @@
}, },
"packages/ssr": { "packages/ssr": {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/styles": { "packages/styles": {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/uploader": "workspace:*", "@wrnexus/uploader": "workspace:*",
}, },
}, },
"packages/test": { "packages/test": {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/tracking": { "packages/tracking": {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.2.17", "version": "0.2.74",
}, },
"packages/ui": { "packages/ui": {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/uploader": { "packages/uploader": {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.2.17", "version": "0.2.74",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/validation": { "packages/validation": {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.2.17", "version": "0.2.74",
}, },
}, },
"overrides": { "overrides": {
@@ -445,6 +454,8 @@
"@wrnexus/dev-server": ["@wrnexus/dev-server@workspace:packages/dev-server"], "@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/encryption": ["@wrnexus/encryption@workspace:packages/encryption"],
"@wrnexus/helpers": ["@wrnexus/helpers@workspace:packages/helpers"], "@wrnexus/helpers": ["@wrnexus/helpers@workspace:packages/helpers"],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+8
View File
@@ -743,6 +743,14 @@ const MIGRATIONS: Migration[] = [
// Language and shared UI behavior improve automatically after updating. // 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. */ /** Release tooling uses this to require an explicit migration entry per version. */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/dev-toolbar": "workspace:*",
"@wrnexus/router": "workspace:*", "@wrnexus/router": "workspace:*",
"@wrnexus/ssr": "workspace:*", "@wrnexus/ssr": "workspace:*",
"@wrnexus/csr": "workspace:*", "@wrnexus/csr": "workspace:*",
+11
View File
@@ -31,6 +31,17 @@ export class HmrHub {
this.sockets.delete(ws); 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 { broadcast(message: HmrMessage): void {
const payload = JSON.stringify(message); const payload = JSON.stringify(message);
for (const ws of this.sockets) { for (const ws of this.sockets) {
+73 -1
View File
@@ -31,6 +31,10 @@ import { startWatcher } from "./watch.ts";
export { RESTART_EXIT_CODE } from "./restart.ts"; export { RESTART_EXIT_CODE } from "./restart.ts";
import { resetDevCache } from "./cache.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 { export interface ServeOptions {
appDir: string; appDir: string;
port?: number; port?: number;
@@ -62,6 +66,7 @@ export interface ServeOptions {
storage?: StorageConfig; storage?: StorageConfig;
mobile?: MobileConfig; mobile?: MobileConfig;
pwa?: PwaConfig | false; pwa?: PwaConfig | false;
devToolbar?: boolean | DevToolbarConfig;
} }
export interface RunningServer { export interface RunningServer {
@@ -112,6 +117,40 @@ async function schemaRuntime(router: Router): Promise<string> {
return renderSchemasScript(descriptors); 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<RunningServer> { export async function startServer(opts: ServeOptions): Promise<RunningServer> {
const appDir = resolve(opts.appDir); const appDir = resolve(opts.appDir);
const mode: Mode = opts.mode ?? "development"; const mode: Mode = opts.mode ?? "development";
@@ -124,6 +163,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
const styleEntry = opts.styleEntry ?? null; const styleEntry = opts.styleEntry ?? null;
const appRoot = dirname(appDir); const appRoot = dirname(appDir);
const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar);
const devToolbarCollector: DevToolbarCollector | undefined = devToolbarConfig
? createDevToolbarCollector()
: undefined;
resetDevCache({ resetDevCache({
rootDir: appRoot, rootDir: appRoot,
enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1", enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1",
@@ -187,6 +232,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
); );
const hub = hmr ? new HmrHub() : undefined; 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 middleware = middlewareLoader(router);
const runtimeDeps = { const runtimeDeps = {
@@ -207,6 +258,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
security: opts.security, security: opts.security,
hub, hub,
realtimeBus: realtimeBusFromConfig(opts.realtime), realtimeBus: realtimeBusFromConfig(opts.realtime),
devToolbar:
devToolbarConfig && devToolbarCollector
? {
config: devToolbarConfig,
collector: devToolbarCollector,
root: appRoot,
}
: undefined,
}; };
const handlers = createHandlers(runtimeDeps); const handlers = createHandlers(runtimeDeps);
@@ -252,8 +311,20 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
console.log(`[wrnexus] hot update — ${files.join(", ")}`); console.log(`[wrnexus] hot update — ${files.join(", ")}`);
hub.reload(); 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; const boundPort = server.port ?? port;
@@ -264,6 +335,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
router, router,
stop: () => { stop: () => {
watcher?.close(); watcher?.close();
unsubscribeDevToolbar?.();
server.stop(); server.stop();
}, },
}; };
+104 -3
View File
@@ -54,6 +54,14 @@ import {
} from "@wrnexus/i18n"; } from "@wrnexus/i18n";
import { runMiddleware } from "./pipeline.ts"; import { runMiddleware } from "./pipeline.ts";
import type { HmrHub } from "./hmr.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. */ /** HTTP methods we recognize as API handler exports. */
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const; 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. * bus (use the Redis pub/sub driver). Enables realtime across multiple apps.
*/ */
realtimeBus?: RealtimeBus; realtimeBus?: RealtimeBus;
devToolbar?: {
config: DevToolbarConfig;
collector: DevToolbarCollector;
root: string;
};
}
const DEV_TOOLBAR_SCRIPT =
'<script type="module" src="/__wrnexus/dev-toolbar.js" data-wrnexus-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) { export const PWA_CLIENT = `if ("serviceWorker" in navigator) {
@@ -429,8 +463,12 @@ export const HMR_CLIENT_JS = `
}; };
ws.onmessage = function (e) { ws.onmessage = function (e) {
var msg; try { msg = JSON.parse(e.data); } catch (_) { return; } var msg; try { msg = JSON.parse(e.data); } catch (_) { return; }
if (msg.type === "css") swapCss(msg.version); if (msg.channel === "toolbar") {
else if (msg.type === "reload") requestSync(); 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 === "html") applyHtml(msg.html);
else if (msg.type === "error") console.error("[wrnexus] HMR update failed:", msg.message); 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. // their reactive scopes (and any browser-side API fetches) in place.
if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document); if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document);
if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(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), // 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 })); 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. --- // --- HMR socket (dev only): upgrade before anything else. ---
if (hmr && url.pathname === "/__wrnexus/hmr") { if (hmr && url.pathname === "/__wrnexus/hmr") {
if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin()); if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin());
@@ -795,6 +845,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
console.error( console.error(
`[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`, `[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)); const response = secure(renderError(err, mode));
// Gateway-managed production apps bind to loopback. Carry a bounded, // Gateway-managed production apps bind to loopback. Carry a bounded,
// encoded diagnostic to the parent gateway so centralized log collectors // 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); result += await renderComponents(rendered, translate, depth + 1);
} catch (err) { } catch (err) {
console.error(`[wrnexus] component '${name}' failed to render`, 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); result += body.slice(tagStart, end);
} }
} }
@@ -1018,6 +1084,20 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
); );
const matched = router.matchPage(ctx.url.pathname); const matched = router.matchPage(ctx.url.pathname);
if (!matched) { 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(); const response = renderNotFound();
if (!isMobileRequest) return response; if (!isMobileRequest) return response;
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
@@ -1065,6 +1145,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
} }
} catch (err) { } catch (err) {
console.error(`[wrnexus] layout '${layoutName}' failed to render`, 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) .filter(Boolean)
.join("\n "), .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, htmlAttrs,
}); });
// Conditional GET: hash the page CONTENT (`body`), not the assembled shell — // 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", "content-type": "text/html; charset=utf-8",
etag: tag, etag: tag,
"cache-control": "private, no-cache", "cache-control": "private, no-cache",
...(shouldEnableDevToolbar(mode, deps)
? {
"x-wrnexus-dev-toolbar": "enabled",
"x-wrnexus-route": matched.route.raw,
}
: {}),
}, },
}); });
} }
+1
View File
@@ -42,6 +42,7 @@ const server = await startServer({
storage: config.storage, storage: config.storage,
mobile: config.mobile, mobile: config.mobile,
pwa: config.pwa, pwa: config.pwa,
devToolbar: config.devToolbar,
}); });
const r = server.router; const r = server.router;
+9
View File
@@ -11,11 +11,13 @@
import { watch, type FSWatcher } from "node:fs"; import { watch, type FSWatcher } from "node:fs";
import type { HmrHub } from "./hmr.ts"; import type { HmrHub } from "./hmr.ts";
import type { DevAssetServer } from "./assets.ts"; import type { DevAssetServer } from "./assets.ts";
import type { DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
export interface WatchOptions { export interface WatchOptions {
appDir: string; appDir: string;
hub: HmrHub; hub: HmrHub;
assets: DevAssetServer; assets: DevAssetServer;
devToolbarCollector?: DevToolbarCollector;
/** Called with changed app-relative files that need an in-process hot update. */ /** Called with changed app-relative files that need an in-process hot update. */
onHotChange: (files: string[]) => void | Promise<void>; onHotChange: (files: string[]) => void | Promise<void>;
} }
@@ -50,6 +52,7 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
pending.clear(); pending.clear();
const files = [...pendingFiles]; const files = [...pendingFiles];
pendingFiles.clear(); pendingFiles.clear();
for (const file of files) opts.devToolbarCollector?.clear(file);
void Promise.resolve(opts.onHotChange(files)).catch((error) => { void Promise.resolve(opts.onHotChange(files)).catch((error) => {
console.error("[wrnexus] hot update failed", error); console.error("[wrnexus] hot update failed", error);
}); });
@@ -58,6 +61,12 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
if (pending.has("css")) { if (pending.has("css")) {
assets.invalidateCss(); assets.invalidateCss();
hub.css(); hub.css();
hub.broadcastJson({
channel: "toolbar",
type: "toolbar:scan",
reason: "styles-change",
files: [...pendingFiles],
});
} }
pending.clear(); pending.clear();
pendingFiles.clear(); pendingFiles.clear();
+78
View File
@@ -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 =
'<script type="module" src="/__wrnexus/dev-toolbar.js" data-wrnexus-dev-toolbar></script>';
return html.includes("</body>")
? html.replace("</body>", `${script}</body>`)
: `${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 } }));
```
+23
View File
@@ -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 `</body>` in development:
```html
<script type="module" src="/__wrnexus/dev-toolbar.js" data-wrnexus-dev-toolbar></script>
```
The browser runtime exposes `window.__wrnexusDevToolbar`.
+25
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { DEV_TOOLBAR_RUNTIME } from "./runtime.ts";
export { DEV_TOOLBAR_CSS } from "./styles.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='<section class="wrn-panel"><header class="wrn-panel-head"><div><div class="wrn-panel-title">WRNexus DevToolbar</div><div class="wrn-panel-meta"></div></div><button class="wrn-small" data-close>Close</button></header><div class="wrn-toolbar-row"><input class="wrn-search" placeholder="Search issues, rules, files…"><button class="wrn-small" data-scan>Rescan</button><button class="wrn-small" data-clear>Clear</button></div><div class="wrn-list"></div></section><nav class="wrn-bar"><span class="wrn-brand">WRNexus</span><button class="wrn-count" data-filter="error"><span class="wrn-dot error"></span><span data-errors>0</span></button><button class="wrn-count" data-filter="warning"><span class="wrn-dot warning"></span><span data-warnings>0</span></button><button class="wrn-count" data-filter="suggestion"><span class="wrn-dot suggestion"></span><span data-suggestions>0</span></button><button class="wrn-button" data-toggle>Inspect</button><button class="wrn-button" data-scan>↻</button></nav>';
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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[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='<div class="wrn-empty">No matching issues. Run a fresh scan after changing the page.</div>';return;}list.innerHTML=filtered.map(x=>'<article class="wrn-issue"><span class="wrn-severity '+esc(x.severity)+'"></span><div><div class="wrn-issue-title">'+esc(x.title)+'</div><div class="wrn-issue-message">'+esc(x.message)+'</div><div class="wrn-issue-meta">'+esc(x.category)+' · '+esc(x.ruleId)+(x.source?.file?' · '+esc(x.source.file):'')+'</div></div><div class="wrn-actions">'+(x.target?.selector?'<button class="wrn-small" data-highlight="'+esc(x.target.selector)+'">Show</button>':'')+'</div></article>').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);
})();`;
@@ -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}}
`;
+4
View File
@@ -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";
@@ -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<string>();
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.',
}),
),
},
];
+48
View File
@@ -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;
},
},
];
+65
View File
@@ -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<HTMLFormElement>("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;
},
},
];
+153
View File
@@ -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<string, unknown>;
}): 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];
}
+74
View File
@@ -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;
},
},
];
+98
View File
@@ -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<HTMLImageElement>("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;
},
},
];
+62
View File
@@ -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<DevToolbarIssue[]> {
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<string, DevToolbarIssue>();
for (const issue of settled.flat()) unique.set(issue.fingerprint, issue);
return [...unique.values()];
}
+69
View File
@@ -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<HTMLAnchorElement>("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;
},
},
];
+78
View File
@@ -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<HTMLVideoElement>("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<HTMLIFrameElement>("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;
},
},
];
@@ -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;
},
},
];
@@ -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;
},
},
];
@@ -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<HTMLFormElement>('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;
},
},
];
+113
View File
@@ -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<HTMLMetaElement>('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 70170 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;
},
},
];
+18
View File
@@ -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<string, unknown>;
}
export interface DevToolbarRule {
id: string;
category: DevToolbarCategory;
defaultSeverity: DevToolbarSeverity;
description: string;
run(context: DevToolbarRuleContext): DevToolbarIssue[] | Promise<DevToolbarIssue[]>;
}
@@ -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<string, DevToolbarIssue>();
const listeners = new Set<DevToolbarIssueListener>();
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);
},
};
}
+61
View File
@@ -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, (file: string, line: number, column: number) => 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) });
}
+6
View File
@@ -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";
+60
View File
@@ -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.",
});
}
@@ -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<string, DevToolbarApp>();
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));
},
};
}
+59
View File
@@ -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<Response | null> {
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 });
}
@@ -0,0 +1,8 @@
export function serializeDevToolbarJson(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
+134
View File
@@ -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<string, unknown>;
}
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<Record<string, boolean>>;
severity?: Partial<Record<string, DevToolbarSeverity>>;
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<DevToolbarPageReport>;
clear(): void;
report(): DevToolbarPageReport | null;
highlight(selector: string): void;
configure(config: DevToolbarConfig): void;
}
declare global {
interface Window {
__wrnexusDevToolbar?: DevToolbarClientApi;
}
}
@@ -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");
});
@@ -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);
});
});
@@ -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`]);
});
});
@@ -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: "</script>&" });
expect(value).not.toContain("</script>");
expect(value).toContain("\\u003c");
});
+13
View File
@@ -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"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.2.74", "version": "0.2.75",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+22
View File
@@ -111,7 +111,29 @@ export interface PwaConfig {
cacheName?: string; 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<Record<string, boolean>>;
severity?: Partial<Record<string, "error" | "warning" | "info" | "suggestion">>;
ignoredRules?: string[];
ignoredPaths?: string[];
slowRequestMs?: number;
largeImageBytes?: number;
veryLargeImageBytes?: number;
}
export interface AppConfig { export interface AppConfig {
/** Development-only page diagnostics toolbar. Enabled by default in development. */
devToolbar?: boolean | DevToolbarConfig;
/** Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet links). */ /** Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet links). */
head?: string | string[]; head?: string | string[];
/** Global SEO defaults merged with every page's exported `meta`. */ /** Global SEO defaults merged with every page's exported `meta`. */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.2.74", "version": "0.2.75",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+6 -1
View File
@@ -38,7 +38,12 @@
"@wrnexus/mobile": ["./packages/mobile/src/index.ts"], "@wrnexus/mobile": ["./packages/mobile/src/index.ts"],
"@wrnexus/native": ["./packages/native/src/index.ts"], "@wrnexus/native": ["./packages/native/src/index.ts"],
"@wrnexus/native/*": ["./packages/native/src/*.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"] "exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
+1 -1
View File
@@ -1,7 +1,7 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
const version = "0.2.74"; const version = "0.2.75";
const dependencyGroups = [ const dependencyGroups = [
"dependencies", "dependencies",