Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13dfa31d19 | ||
|
|
c6fc0f1f63 | ||
|
|
7ff5b3e8c5 | ||
|
|
569365143b | ||
|
|
69b6cd431f |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/authz — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/cli — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -28,16 +28,16 @@
|
||||
"wrnexus": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74",
|
||||
"@wrnexus/router": "^0.2.74",
|
||||
"@wrnexus/csr": "^0.2.74",
|
||||
"@wrnexus/compiler": "^0.2.74",
|
||||
"@wrnexus/styles": "^0.2.74",
|
||||
"@wrnexus/dev-server": "^0.2.74",
|
||||
"@wrnexus/ui": "^0.2.74",
|
||||
"@wrnexus/validation": "^0.2.74",
|
||||
"@wrnexus/i18n": "^0.2.74",
|
||||
"@wrnexus/db": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79",
|
||||
"@wrnexus/router": "^0.2.79",
|
||||
"@wrnexus/csr": "^0.2.79",
|
||||
"@wrnexus/compiler": "^0.2.79",
|
||||
"@wrnexus/styles": "^0.2.79",
|
||||
"@wrnexus/dev-server": "^0.2.79",
|
||||
"@wrnexus/ui": "^0.2.79",
|
||||
"@wrnexus/validation": "^0.2.79",
|
||||
"@wrnexus/i18n": "^0.2.79",
|
||||
"@wrnexus/db": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/compiler — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/core — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/csr — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/db — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -25,18 +25,19 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74",
|
||||
"@wrnexus/router": "^0.2.74",
|
||||
"@wrnexus/ssr": "^0.2.74",
|
||||
"@wrnexus/csr": "^0.2.74",
|
||||
"@wrnexus/compiler": "^0.2.74",
|
||||
"@wrnexus/styles": "^0.2.74",
|
||||
"@wrnexus/ui": "^0.2.74",
|
||||
"@wrnexus/validation": "^0.2.74",
|
||||
"@wrnexus/i18n": "^0.2.74",
|
||||
"@wrnexus/db": "^0.2.74",
|
||||
"@wrnexus/pubsub": "^0.2.74",
|
||||
"@wrnexus/uploader": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79",
|
||||
"@wrnexus/dev-toolbar": "^0.2.79",
|
||||
"@wrnexus/router": "^0.2.79",
|
||||
"@wrnexus/ssr": "^0.2.79",
|
||||
"@wrnexus/csr": "^0.2.79",
|
||||
"@wrnexus/compiler": "^0.2.79",
|
||||
"@wrnexus/styles": "^0.2.79",
|
||||
"@wrnexus/ui": "^0.2.79",
|
||||
"@wrnexus/validation": "^0.2.79",
|
||||
"@wrnexus/i18n": "^0.2.79",
|
||||
"@wrnexus/db": "^0.2.79",
|
||||
"@wrnexus/pubsub": "^0.2.79",
|
||||
"@wrnexus/uploader": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.2.79",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/encryption — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/i18n — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/jwt — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/mobile — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/native": "^0.2.74"
|
||||
"@wrnexus/native": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/native — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/oauth — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/queue — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/reactive — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/router — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,8 +21,8 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "^0.2.74",
|
||||
"@wrnexus/core": "^0.2.74"
|
||||
"@wrnexus/compiler": "^0.2.79",
|
||||
"@wrnexus/core": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/ssr — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/styles — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/uploader": "^0.2.74"
|
||||
"@wrnexus/uploader": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/test — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/tracking — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
+18
-18
@@ -933,15 +933,6 @@ Reusable campaign performance preview component.
|
||||
- Slots: `default`
|
||||
- Events: None
|
||||
|
||||
### Card
|
||||
|
||||
Reusable card component.
|
||||
|
||||
- Mount: `data-component="Card"`
|
||||
- Props: `variant: string = "default"`, `padding: string = "md"`, `interactive: boolean = false`, `className: string = ""`
|
||||
- Slots: `default`
|
||||
- Events: None
|
||||
|
||||
### CardPaymentForm
|
||||
|
||||
Reusable card payment form component.
|
||||
@@ -2879,6 +2870,15 @@ Reusable zoom slider component.
|
||||
|
||||
## Core
|
||||
|
||||
### alert
|
||||
|
||||
Reusable alert component.
|
||||
|
||||
- Mount: `data-component="alert"`
|
||||
- Props: `class: string = ""`, `title: string = "Notice"`, `description: string = ""`, `variant: string = "info"`, `dismissible: boolean = false`
|
||||
- Slots: `default`
|
||||
- Events: `click`
|
||||
|
||||
### AnnouncementBar
|
||||
|
||||
Reusable announcement bar component.
|
||||
@@ -2915,6 +2915,15 @@ Reusable breadcrumbs component.
|
||||
- Slots: None
|
||||
- Events: None
|
||||
|
||||
### card
|
||||
|
||||
Reusable card component.
|
||||
|
||||
- Mount: `data-component="card"`
|
||||
- Props: `variant: string = "default"`, `padding: string = "md"`, `interactive: boolean = false`, `className: string = ""`
|
||||
- Slots: `default`
|
||||
- Events: None
|
||||
|
||||
### Checkbox
|
||||
|
||||
Reusable checkbox component.
|
||||
@@ -4206,15 +4215,6 @@ Reusable account status banner component.
|
||||
- Slots: `default`
|
||||
- Events: None
|
||||
|
||||
### Alert
|
||||
|
||||
Reusable alert component.
|
||||
|
||||
- Mount: `data-component="Alert"`
|
||||
- Props: `class: string = ""`, `title: string = "Notice"`, `description: string = ""`, `variant: string = "info"`, `dismissible: boolean = false`
|
||||
- Slots: `default`
|
||||
- Events: `click`
|
||||
|
||||
### ApiAuthenticationNotice
|
||||
|
||||
Reusable api authentication notice component.
|
||||
|
||||
@@ -1039,9 +1039,9 @@
|
||||
"source": "components/AdvancedFilterBuilder.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Alert",
|
||||
"mount": "Alert",
|
||||
"category": "feedback",
|
||||
"name": "alert",
|
||||
"mount": "alert",
|
||||
"category": "core",
|
||||
"purpose": "Reusable alert component.",
|
||||
"props": [
|
||||
{
|
||||
@@ -1077,7 +1077,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": ["click"],
|
||||
"source": "components/Alert.wrn"
|
||||
"source": "components/alert.wrn"
|
||||
},
|
||||
{
|
||||
"name": "AlertDialog",
|
||||
@@ -5604,9 +5604,9 @@
|
||||
"source": "components/CaptchaField.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Card",
|
||||
"mount": "Card",
|
||||
"category": "content",
|
||||
"name": "card",
|
||||
"mount": "card",
|
||||
"category": "core",
|
||||
"purpose": "Reusable card component.",
|
||||
"props": [
|
||||
{
|
||||
@@ -5636,7 +5636,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Card.wrn"
|
||||
"source": "components/card.wrn"
|
||||
},
|
||||
{
|
||||
"name": "CardPaymentForm",
|
||||
|
||||
@@ -9,7 +9,63 @@ component AnalyticsDashboardPreview {
|
||||
alt = ""
|
||||
class = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<article class="wire-card wire-catalog-card {class}">{#if image}<img src="{image}" alt="{alt}" class="wire-catalog-card__image" />{:else}<div role="img" aria-label="Communication performance chart trending upward over seven days" class="grid min-h-64 gap-4 rounded-xl border border-slate-200 bg-slate-50 p-5 dark:border-slate-800 dark:bg-slate-950/60"><div class="flex items-center justify-between"><span class="text-sm font-semibold">Delivery trend</span><span class="inline-flex items-center gap-1 text-xs font-bold text-emerald-500"><span class="icon-[lucide--trending-up] size-4" aria-hidden="true"></span>18.4%</span></div><svg viewBox="0 0 600 190" role="presentation" class="h-auto w-full overflow-visible"><defs><linearGradient id="wire-analytics-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="currentColor" stop-opacity="0.28"></stop><stop offset="100%" stop-color="currentColor" stop-opacity="0"></stop></linearGradient></defs><g stroke="currentColor" stroke-opacity="0.12"><path d="M0 30H600M0 80H600M0 130H600M0 180H600"></path></g><path d="M0 157 C55 150 70 118 124 126 S205 152 255 103 S342 91 392 98 S470 67 515 74 S566 38 600 30 L600 190 L0 190 Z" fill="url(#wire-analytics-fill)" class="text-violet-500"></path><path d="M0 157 C55 150 70 118 124 126 S205 152 255 103 S342 91 392 98 S470 67 515 74 S566 38 600 30" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" class="text-violet-500"></path><g fill="currentColor" class="text-violet-500"><circle cx="124" cy="126" r="5"></circle><circle cx="255" cy="103" r="5"></circle><circle cx="392" cy="98" r="5"></circle><circle cx="515" cy="74" r="5"></circle><circle cx="600" cy="30" r="6"></circle></g></svg><div class="flex justify-between text-[0.6875rem] text-slate-500"><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span></div></div>{/if}{#if eyebrow}<span class="wire-eyebrow">{eyebrow}</span>{/if}{#if title}<h3>{title}</h3>{/if}{#if description}<p>{description}</p>{/if}<slot />{#if href}<a href="{href}" class="wire-btn wire-btn--ghost">{actionLabel}</a>{/if}</article>
|
||||
<section class="px-4 py-10 sm:px-6 sm:py-12 lg:px-8 lg:py-14 {class}">
|
||||
<div
|
||||
class="mx-auto grid w-full max-w-7xl gap-8 overflow-hidden rounded-3xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-5 shadow-[var(--wire-shadow-1,0_12px_30px_rgb(0_0_0/0.08))] sm:p-7 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,0.85fr)] lg:items-center lg:p-8"
|
||||
>
|
||||
<div>
|
||||
{#if image}
|
||||
<img
|
||||
src="{image}"
|
||||
alt="{alt}"
|
||||
class="aspect-[16/9] w-full rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface-2,#f8fafc)] p-4 sm:p-5"
|
||||
role="img"
|
||||
aria-label="Delivery trend rising over seven days"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 text-sm">
|
||||
<strong class="text-[var(--wire-color-text,#0f172a)]">Delivery trend</strong>
|
||||
<span class="font-bold text-[var(--wire-color-success,#059669)]">↗ 18.4%</span>
|
||||
</div>
|
||||
<svg
|
||||
class="mt-4 h-auto w-full text-[var(--wire-color-primary,#059669)]"
|
||||
viewBox="0 0 700 250"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="wire-analytics-area"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity=".24"></stop>
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="none" stroke="var(--wire-color-border,#e2e8f0)"><path d="M20 50H680M20 115H680M20 180H680M20 230H680"></path></g>
|
||||
<path fill="url(#wire-analytics-area)" d="M20 210 C100 195 120 150 190 165 S310 195 370 120 S475 130 525 100 S610 90 680 32 L680 230 L20 230Z"></path>
|
||||
<path fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" d="M20 210 C100 195 120 150 190 165 S310 195 370 120 S475 130 525 100 S610 90 680 32"></path>
|
||||
</svg>
|
||||
<div class="mt-2 flex justify-between text-[0.7rem] text-[var(--wire-color-muted,#64748b)]"><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if eyebrow}<p class="m-0 text-xs font-bold uppercase tracking-[0.16em] text-[var(--wire-color-primary,#059669)]">{eyebrow}</p>{/if}
|
||||
{#if title}<h2 class="mt-4 text-3xl font-semibold tracking-[-0.035em] text-[var(--wire-color-text,#0f172a)]">{title}</h2>{/if}
|
||||
{#if description}<p class="mt-4 leading-7 text-[var(--wire-color-muted,#64748b)]">{description}</p>{/if}
|
||||
<div class="mt-5"><slot /></div>
|
||||
{#if href}<a href="{href}" class="mt-6 inline-flex items-center gap-2 font-semibold text-[var(--wire-color-primary,#059669)] no-underline">{actionLabel}<span aria-hidden="true">→</span></a>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,33 @@ component ChannelCard {
|
||||
title = ""
|
||||
description = ""
|
||||
href = ""
|
||||
icon = "icon-[lucide--radio-tower]"
|
||||
icon = ""
|
||||
class = ""
|
||||
}
|
||||
view { <article class="wire-card wire-catalog-card wire-catalog-card--channel {class}"><span class="wire-catalog-card__icon"><span class="{icon} size-5" aria-hidden="true"></span></span>{#if title}<h3>{title}</h3>{/if}{#if description}<p>{description}</p>{/if}<slot />{#if href}<a href="{href}" class="wire-catalog-card__link"><span class="wire-visually-hidden">Open {title}</span></a>{/if}</article> }
|
||||
|
||||
view {
|
||||
<article
|
||||
class="group flex h-full min-h-72 flex-col rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-6 shadow-[var(--wire-shadow-1,0_1px_2px_rgb(0_0_0/0.05))] transition duration-200 hover:-translate-y-1 hover:border-[var(--wire-color-primary,#059669)] hover:shadow-[var(--wire-shadow-1,0_12px_30px_rgb(0_0_0/0.10))] {class}"
|
||||
>
|
||||
<div class="flex min-h-12 items-start justify-between gap-4">
|
||||
{#if icon}
|
||||
<span class="inline-grid h-12 w-12 shrink-0 place-items-center rounded-xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-primary-soft,#ecfdf5)] text-xl text-[var(--wire-color-primary,#059669)] {icon}" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<div class="flex min-w-0 justify-end"><slot /></div>
|
||||
</div>
|
||||
|
||||
{#if title}<h3 class="mt-5 text-xl font-semibold tracking-[-0.02em] text-[var(--wire-color-text,#0f172a)]">{title}</h3>{/if}
|
||||
{#if description}<p class="mt-3 flex-1 leading-7 text-[var(--wire-color-muted,#64748b)]">{description}</p>{/if}
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
href="{href}"
|
||||
class="mt-6 inline-flex items-center justify-between gap-3 border-t border-[var(--wire-color-border,#e2e8f0)] pt-5 font-semibold text-[var(--wire-color-primary,#059669)] no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)]"
|
||||
aria-label="Explore {title}"
|
||||
>
|
||||
<span>Explore {title}</span><span class="transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
||||
</a>
|
||||
{/if}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
component ChannelFlow {
|
||||
props {
|
||||
title = ""
|
||||
value = ""
|
||||
description = ""
|
||||
summary = ""
|
||||
loading = false
|
||||
class = ""
|
||||
}
|
||||
view { <figure aria-label="{summary || title}" class="wire-card wire-catalog-visualization {class}"><figcaption><strong data-show="title">{title}</strong><span data-show="value" class="wire-metric__value">{value}</span><span data-show="description" class="wire-muted">{description}</span></figcaption><div data-show="loading" class="wire-skeleton wire-catalog-visualization__skeleton"></div><div data-show="!loading" class="wire-catalog-visualization__content"><slot /></div><p data-show="summary" class="wire-visually-hidden">{summary}</p></figure> }
|
||||
|
||||
view {
|
||||
<div
|
||||
class="grid grid-cols-1 items-center gap-3 lg:grid-cols-[1fr_auto_1fr_auto_1.2fr_auto_1fr] {class}"
|
||||
role="img"
|
||||
aria-label="Customer event flows through profile and consent checks to communication channels, then delivery analytics and webhooks"
|
||||
>
|
||||
<div
|
||||
class="rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-4"
|
||||
>
|
||||
<span class="icon-[lucide--webhook] text-xl text-[var(--wire-color-primary,#059669)]" aria-hidden="true"></span>
|
||||
<strong class="mt-3 block text-sm text-[var(--wire-color-text,#0f172a)]">Customer event</strong>
|
||||
<small class="mt-1 block text-[var(--wire-color-muted,#64748b)]">order.shipped</small>
|
||||
</div>
|
||||
<span class="hidden text-center text-xl text-[var(--wire-color-primary,#059669)] lg:block" aria-hidden="true">→</span>
|
||||
|
||||
<div
|
||||
class="rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-4"
|
||||
>
|
||||
<span class="icon-[lucide--shield-check] text-xl text-[var(--wire-color-primary,#059669)]" aria-hidden="true"></span>
|
||||
<strong class="mt-3 block text-sm text-[var(--wire-color-text,#0f172a)]">Profile + consent</strong>
|
||||
<small class="mt-1 block text-[var(--wire-color-muted,#64748b)]">Eligibility checked</small>
|
||||
</div>
|
||||
<span class="hidden text-center text-xl text-[var(--wire-color-primary,#059669)] lg:block" aria-hidden="true">→</span>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-2 rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-3"
|
||||
>
|
||||
<span class="rounded-xl bg-[var(--wire-color-primary-soft,#ecfdf5)] px-3 py-2.5 text-center text-xs font-bold text-[var(--wire-color-primary,#059669)]">WhatsApp</span>
|
||||
<span class="rounded-xl bg-[var(--wire-color-primary-soft,#ecfdf5)] px-3 py-2.5 text-center text-xs font-bold text-[var(--wire-color-primary,#059669)]">SMS</span>
|
||||
<span class="rounded-xl bg-[var(--wire-color-primary-soft,#ecfdf5)] px-3 py-2.5 text-center text-xs font-bold text-[var(--wire-color-primary,#059669)]">Email</span>
|
||||
<span class="rounded-xl bg-[var(--wire-color-primary-soft,#ecfdf5)] px-3 py-2.5 text-center text-xs font-bold text-[var(--wire-color-primary,#059669)]">Voice</span>
|
||||
</div>
|
||||
<span class="hidden text-center text-xl text-[var(--wire-color-primary,#059669)] lg:block" aria-hidden="true">→</span>
|
||||
|
||||
<div
|
||||
class="rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-4"
|
||||
>
|
||||
<span class="icon-[lucide--chart-no-axes-combined] text-xl text-[var(--wire-color-primary,#059669)]" aria-hidden="true"></span>
|
||||
<strong class="mt-3 block text-sm text-[var(--wire-color-text,#0f172a)]">Delivery + analytics</strong>
|
||||
<small class="mt-1 block text-[var(--wire-color-muted,#64748b)]">Webhook emitted</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
component Cluster {
|
||||
props {
|
||||
gap = "4"
|
||||
className = ""
|
||||
class = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<div class="flex flex-wrap items-center gap-{gap} {className}"><slot /></div>
|
||||
<div class="flex flex-wrap items-center gap-3 {class}">
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
component CodeBlock {
|
||||
props {
|
||||
class = ""
|
||||
language = "text"
|
||||
filename = ""
|
||||
title = ""
|
||||
code = ""
|
||||
copyable = true
|
||||
class = ""
|
||||
}
|
||||
state copied = false
|
||||
|
||||
view {
|
||||
<div class="min-w-0 overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/20 {class}"><div class="flex min-h-12 items-center justify-between gap-4 border-b border-slate-800 px-4 text-xs text-slate-400"><span class="inline-flex min-w-0 items-center gap-2 truncate"><span class="icon-[lucide--terminal] size-4 shrink-0" aria-hidden="true"></span>{filename || language}</span><button type="button" @click="navigator.clipboard.writeText(code); copied = true" class="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-700 px-2.5 py-1.5 font-semibold text-slate-300 transition hover:border-slate-600 hover:bg-slate-800 hover:text-white"><span class="{copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'} size-3.5" aria-hidden="true"></span>{copied ? 'Copied' : 'Copy'}</button></div><pre class="m-0 max-w-full overflow-x-auto whitespace-pre-wrap break-words p-5 font-mono text-[0.8125rem] leading-6 sm:whitespace-pre"><code>{code}</code></pre></div>
|
||||
<section class="overflow-hidden bg-[var(--wire-color-code-background,#020617)] text-[var(--wire-color-code-text,#e2e8f0)] {class}">
|
||||
<header class="flex items-center justify-between gap-4 border-b border-[var(--wire-color-code-border,#1e293b)] px-4 py-3 text-xs text-[var(--wire-color-code-muted,#94a3b8)] sm:px-5">
|
||||
<span class="inline-flex items-center gap-2 font-medium">
|
||||
<span class="icon-[lucide--terminal]" aria-hidden="true"></span>
|
||||
{#if title}{title}{:else}{language}{/if}
|
||||
</span>
|
||||
{#if copyable}
|
||||
<button type="button" data-copy-code="{code}" class="inline-flex items-center gap-2 rounded-lg border border-[var(--wire-color-code-border,#334155)] px-3 py-1.5 font-semibold text-[var(--wire-color-code-text,#e2e8f0)] transition hover:bg-[var(--wire-color-code-surface,#0f172a)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)]">
|
||||
<span class="icon-[lucide--copy]" aria-hidden="true"></span>Copy
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
<pre class="m-0 max-w-full overflow-x-auto p-5 text-sm leading-7"><code>{code}</code></pre>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
component CodeTabs {
|
||||
props {
|
||||
eyebrow = ""
|
||||
title = ""
|
||||
description = ""
|
||||
href = ""
|
||||
actionLabel = "Learn more"
|
||||
image = ""
|
||||
alt = ""
|
||||
class = ""
|
||||
}
|
||||
view { <article class="wire-card wire-catalog-card {class}">{#if image}<img src="{image}" alt="{alt}" class="wire-catalog-card__image" />{/if}{#if eyebrow}<span class="wire-eyebrow">{eyebrow}</span>{/if}{#if title}<h3>{title}</h3>{/if}{#if description}<p>{description}</p>{/if}<slot />{#if href}<a href="{href}" class="wire-btn wire-btn--ghost">{actionLabel}</a>{/if}</article> }
|
||||
|
||||
view {
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] shadow-[var(--wire-shadow-1,0_1px_2px_rgb(0_0_0/0.05))] {class}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,24 @@ component FeatureTabs {
|
||||
alt = ""
|
||||
class = ""
|
||||
}
|
||||
view { <article class="wire-card wire-catalog-card {class}">{#if image}<img src="{image}" alt="{alt}" class="wire-catalog-card__image" />{/if}{#if eyebrow}<span class="wire-eyebrow">{eyebrow}</span>{/if}{#if title}<h3>{title}</h3>{/if}{#if description}<p>{description}</p>{/if}<slot />{#if href}<a href="{href}" class="wire-btn wire-btn--ghost">{actionLabel}</a>{/if}</article> }
|
||||
|
||||
view {
|
||||
<article class="group flex h-full min-h-72 flex-col overflow-hidden rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] shadow-[var(--wire-shadow-1,0_1px_2px_rgb(0_0_0/0.05))] transition duration-200 hover:-translate-y-1 hover:border-[var(--wire-color-primary,#059669)] hover:shadow-[var(--wire-shadow-1,0_12px_30px_rgb(0_0_0/0.10))] {class}">
|
||||
{#if image}
|
||||
<img src="{image}" alt="{alt}" class="aspect-[16/9] w-full border-b border-[var(--wire-color-border,#e2e8f0)] object-cover" />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-1 flex-col p-6">
|
||||
{#if eyebrow}<p class="m-0 text-xs font-bold uppercase tracking-[0.14em] text-[var(--wire-color-primary,#059669)]">{eyebrow}</p>{/if}
|
||||
{#if title}<h3 class="mt-4 text-xl font-semibold leading-snug tracking-[-0.02em] text-[var(--wire-color-text,#0f172a)]">{title}</h3>{/if}
|
||||
{#if description}<p class="mt-3 flex-1 leading-7 text-[var(--wire-color-muted,#64748b)]">{description}</p>{/if}
|
||||
<div class="mt-5"><slot /></div>
|
||||
{#if href}
|
||||
<a href="{href}" class="mt-6 inline-flex items-center gap-2 font-semibold text-[var(--wire-color-primary,#059669)] no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)]">
|
||||
<span>{actionLabel}</span><span class="transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,68 @@
|
||||
component FinalCTA {
|
||||
props {
|
||||
label = "Action"
|
||||
label = "Get started"
|
||||
href = ""
|
||||
type = "button"
|
||||
variant = "primary"
|
||||
disabled = false
|
||||
class = ""
|
||||
}
|
||||
view { <span class="wire-catalog-action {class}">{#if href}<a href="{href}" class="wire-btn wire-btn--{variant}">{label}<slot /></a>{:else}<button type="{type}" disabled="{disabled}" class="wire-btn wire-btn--{variant}">{label}<slot /></button>{/if}</span> }
|
||||
|
||||
view {
|
||||
<span class="contents {class}">
|
||||
{#if href}
|
||||
{#if variant === "primary"}
|
||||
<a
|
||||
href="{href}"
|
||||
class="group inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-[var(--wire-color-primary,#059669)] px-5 py-2.5 text-sm font-semibold text-[var(--wire-color-primary-contrast,#ffffff)] no-underline transition duration-200 hover:bg-[var(--wire-color-primary-hover,#047857)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)] focus-visible:ring-offset-2"
|
||||
aria-disabled="{disabled}"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
class="transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href="{href}"
|
||||
class="group inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] px-5 py-2.5 text-sm font-semibold text-[var(--wire-color-text,#0f172a)] no-underline transition duration-200 hover:border-[var(--wire-color-primary,#059669)] hover:text-[var(--wire-color-primary,#059669)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)] focus-visible:ring-offset-2"
|
||||
aria-disabled="{disabled}"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
class="transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<slot />
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if variant === "primary"}
|
||||
<button
|
||||
type="{type}"
|
||||
disabled="{disabled}"
|
||||
class="group inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-[var(--wire-color-primary,#059669)] px-5 py-2.5 text-sm font-semibold text-[var(--wire-color-primary-contrast,#ffffff)] transition duration-200 hover:bg-[var(--wire-color-primary-hover,#047857)] disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)] focus-visible:ring-offset-2"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<slot />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="{type}"
|
||||
disabled="{disabled}"
|
||||
class="group inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] px-5 py-2.5 text-sm font-semibold text-[var(--wire-color-text,#0f172a)] transition duration-200 hover:border-[var(--wire-color-primary,#059669)] hover:text-[var(--wire-color-primary,#059669)] disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)] focus-visible:ring-offset-2"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,30 @@ component MarketingSectionHeader {
|
||||
align = "start"
|
||||
class = ""
|
||||
}
|
||||
view { <section class="wire-catalog-section wire-text--{align} {class}"><header data-show="eyebrow || title || description"><span data-show="eyebrow" class="wire-eyebrow">{eyebrow}</span><h2 data-show="title">{title}</h2><p data-show="description" class="wire-muted">{description}</p></header><slot /></section> }
|
||||
|
||||
view {
|
||||
<section class="px-4 py-12 sm:px-6 sm:py-16 lg:px-8 lg:py-20 {class}">
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
<header
|
||||
class="max-w-3xl"
|
||||
class:mx-auto='{align === "center"}'
|
||||
class:text-center='{align === "center"}'
|
||||
>
|
||||
{#if eyebrow}
|
||||
<p class="mb-3 text-xs font-bold uppercase tracking-[0.16em] text-[var(--wire-color-primary,#059669)] sm:text-sm">{eyebrow}</p>
|
||||
{/if}
|
||||
{#if title}
|
||||
<h2 class="m-0 text-3xl font-semibold tracking-[-0.035em] text-[var(--wire-color-text,#0f172a)] sm:text-4xl">{title}</h2>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-5 text-base leading-8 text-[var(--wire-color-muted,#64748b)] sm:text-lg">{description}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="mt-10">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
component MetricGrid {
|
||||
props {
|
||||
label = "Key metrics"
|
||||
label = "Platform metrics"
|
||||
metrics = []
|
||||
class = ""
|
||||
}
|
||||
view { <section aria-label="{label}" class="wire-metric-grid {class}">{#each metrics as metric}<article class="wire-metric"><p class="wire-metric__value">{metric.value}</p><p class="wire-metric__label">{metric.label}</p><p data-show="metric.description" class="wire-metric__description">{metric.description}</p></article>{/each}</section> }
|
||||
|
||||
view {
|
||||
<section class="px-4 sm:px-6 lg:px-8 {class}" aria-label="{label}">
|
||||
<div class="mx-auto grid w-full max-w-7xl grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{#each metrics as metric}
|
||||
<article class="flex min-h-40 flex-col justify-center rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-6 shadow-[var(--wire-shadow-1,0_1px_2px_rgb(0_0_0/0.05))]">
|
||||
<strong class="text-3xl font-semibold leading-none tracking-[-0.04em] text-[var(--wire-color-text,#0f172a)] sm:text-4xl">
|
||||
{metric.value}
|
||||
</strong>
|
||||
<span class="mt-4 font-semibold text-[var(--wire-color-text,#0f172a)]">{metric.label}</span>
|
||||
{#if metric.description}
|
||||
<p class="mt-2 text-sm leading-6 text-[var(--wire-color-muted,#64748b)]">{metric.description}</p>
|
||||
{/if}
|
||||
</article>
|
||||
{:empty}
|
||||
<p class="col-span-full rounded-2xl border border-dashed border-[var(--wire-color-border,#e2e8f0)] p-8 text-center text-[var(--wire-color-muted,#64748b)]">
|
||||
No metrics available.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,29 @@ component ProductArchitectureDiagram {
|
||||
loading = false
|
||||
class = ""
|
||||
}
|
||||
view { <figure aria-label="{summary || title}" class="wire-card wire-catalog-visualization {class}"><figcaption><strong data-show="title">{title}</strong><span data-show="value" class="wire-metric__value">{value}</span><span data-show="description" class="wire-muted">{description}</span></figcaption><div data-show="loading" class="wire-skeleton wire-catalog-visualization__skeleton"></div><div data-show="!loading" class="wire-catalog-visualization__content"><slot /></div><p data-show="summary" class="wire-visually-hidden">{summary}</p></figure> }
|
||||
|
||||
view {
|
||||
<section class="overflow-hidden rounded-3xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] shadow-[var(--wire-shadow-1,0_12px_30px_rgb(0_0_0/0.08))] {class}" aria-label="{title}">
|
||||
<header class="flex flex-col gap-4 border-b border-[var(--wire-color-border,#e2e8f0)] p-6 sm:flex-row sm:items-start sm:justify-between lg:p-8">
|
||||
<div>
|
||||
{#if title}<p class="m-0 font-semibold text-[var(--wire-color-text,#0f172a)]">{title}</p>{/if}
|
||||
{#if value}<strong class="mt-3 block text-3xl font-semibold tracking-[-0.04em] text-[var(--wire-color-text,#0f172a)] sm:text-4xl">{value}</strong>{/if}
|
||||
{#if description}<p class="mt-3 max-w-2xl leading-7 text-[var(--wire-color-muted,#64748b)]">{description}</p>{/if}
|
||||
</div>
|
||||
{#if loading}
|
||||
<span class="inline-flex w-max items-center gap-2 rounded-full bg-[var(--wire-color-surface-2,#f8fafc)] px-3 py-1.5 text-xs font-semibold text-[var(--wire-color-muted,#64748b)]">
|
||||
<span class="h-2 w-2 animate-pulse rounded-full bg-[var(--wire-color-primary,#059669)]"></span>Loading
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="bg-[var(--wire-color-surface-2,#f8fafc)] p-4 sm:p-6 lg:p-8 empty:hidden">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{#if summary}
|
||||
<p class="m-0 border-t border-[var(--wire-color-border,#e2e8f0)] px-6 py-5 text-sm leading-6 text-[var(--wire-color-muted,#64748b)] lg:px-8">{summary}</p>
|
||||
{/if}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
component ProductCard {
|
||||
props {
|
||||
eyebrow = "Product"
|
||||
title = "Product name"
|
||||
eyebrow = ""
|
||||
title = ""
|
||||
description = ""
|
||||
href = "#"
|
||||
href = ""
|
||||
actionLabel = "Learn more"
|
||||
status = ""
|
||||
class = ""
|
||||
}
|
||||
view { <article class="wire-product-card {class}"><div class="wire-product-card__meta"><span class="wire-eyebrow">{eyebrow}</span><span data-show="status" class="wire-badge">{status}</span></div><h3>{title}</h3><p>{description}</p><a href="{href}" class="wire-product-card__link">{actionLabel}<span aria-hidden="true"> →</span></a></article> }
|
||||
|
||||
view {
|
||||
<article class="group flex h-full min-h-72 flex-col rounded-2xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)] p-6 shadow-[var(--wire-shadow-1,0_1px_2px_rgb(0_0_0/0.05))] transition duration-200 hover:-translate-y-1 hover:border-[var(--wire-color-primary,#059669)] hover:shadow-[var(--wire-shadow-1,0_12px_30px_rgb(0_0_0/0.10))] {class}">
|
||||
<div class="flex min-h-7 items-start justify-between gap-4">
|
||||
{#if eyebrow}<span class="text-xs font-bold uppercase tracking-[0.14em] text-[var(--wire-color-primary,#059669)]">{eyebrow}</span>{/if}
|
||||
{#if status}<span class="shrink-0 rounded-full bg-[var(--wire-color-surface-2,#f8fafc)] px-2.5 py-1 text-[0.7rem] font-bold text-[var(--wire-color-muted,#64748b)]">{status}</span>{/if}
|
||||
</div>
|
||||
{#if title}<h3 class="mt-5 text-xl font-semibold tracking-[-0.02em] text-[var(--wire-color-text,#0f172a)]">{title}</h3>{/if}
|
||||
{#if description}<p class="mt-3 flex-1 leading-7 text-[var(--wire-color-muted,#64748b)]">{description}</p>{/if}
|
||||
{#if href}
|
||||
<a href="{href}" class="mt-6 inline-flex items-center gap-2 border-t border-[var(--wire-color-border,#e2e8f0)] pt-5 font-semibold text-[var(--wire-color-primary,#059669)] no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-primary,#059669)]">
|
||||
<span>{actionLabel}</span><span class="transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
||||
</a>
|
||||
{/if}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,23 @@ component ProductGrid {
|
||||
emptyText = "No items available."
|
||||
class = ""
|
||||
}
|
||||
view { <section class="wire-catalog-collection {class}"><header data-show="title || description"><h2 data-show="title">{title}</h2><p data-show="description">{description}</p></header><div data-show="!empty" role="list" class="wire-catalog-collection__items"><slot /></div><p data-show="empty" role="status" class="wire-muted">{emptyText}</p></section> }
|
||||
|
||||
view {
|
||||
<section class="px-4 py-10 sm:px-6 sm:py-12 lg:px-8 lg:py-14 {class}">
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
{#if title}<h2 class="m-0 text-3xl font-semibold tracking-[-0.035em] text-[var(--wire-color-text,#0f172a)]">{title}</h2>{/if}
|
||||
{#if description}<p class="mt-4 max-w-3xl leading-7 text-[var(--wire-color-muted,#64748b)]">{description}</p>{/if}
|
||||
|
||||
{#if empty}
|
||||
<div class="mt-8 rounded-2xl border border-dashed border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface-2,#f8fafc)] p-10 text-center text-[var(--wire-color-muted,#64748b)]">
|
||||
{emptyText}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-8 grid grid-cols-1 items-stretch gap-6 md:grid-cols-2 xl:grid-cols-3 xl:gap-7 [&>*]:min-w-0 [&>*]:h-full">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,50 @@ component ProductHero {
|
||||
align = "start"
|
||||
class = ""
|
||||
}
|
||||
view { <section class="wire-catalog-section wire-text--{align} {class}"><header data-show="eyebrow || title || description"><span data-show="eyebrow" class="wire-eyebrow">{eyebrow}</span><h2 data-show="title">{title}</h2><p data-show="description" class="wire-muted">{description}</p></header><slot /></section> }
|
||||
|
||||
view {
|
||||
<section
|
||||
class=" relative isolate overflow-hidden border-b border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-bg,#ffffff)] px-4 py-14 sm:px-6 sm:py-18 lg:px-8 lg:py-20 {class} "
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-0 -z-10 opacity-80" style="background: radial-gradient(circle at 16% 0%, color-mix(in srgb, var(--wire-color-primary, #059669) 16%, transparent), transparent 36%);" aria-hidden="true" ></div>
|
||||
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
<div
|
||||
class="max-w-4xl"
|
||||
class:mx-auto='{align === "center"}'
|
||||
class:text-center='{align === "center"}'
|
||||
>
|
||||
{#if eyebrow}
|
||||
<p
|
||||
class="mb-4 text-xs font-bold uppercase tracking-[0.18em] text-[var(--wire-color-primary,#059669)] sm:text-sm"
|
||||
>
|
||||
{eyebrow}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if title}
|
||||
<h1
|
||||
class="m-0 max-w-[18ch] text-4xl font-semibold leading-[1.05] tracking-[-0.045em] text-[var(--wire-color-text,#0f172a)] sm:text-5xl lg:text-6xl"
|
||||
class:mx-auto='{align === "center"}'
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{/if}
|
||||
|
||||
{#if description}
|
||||
<p
|
||||
class="mt-6 max-w-3xl text-base leading-8 text-[var(--wire-color-muted,#64748b)] sm:text-lg"
|
||||
class:mx-auto='{align === "center"}'
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-10 space-y-10">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,12 @@ component ProductPageShell {
|
||||
props {
|
||||
class = ""
|
||||
}
|
||||
view { <div class="wire-page-shell wire-page-shell--product {class}"><slot /></div> }
|
||||
|
||||
view {
|
||||
<main
|
||||
class=" min-h-screen w-full overflow-x-clip bg-[var(--wire-color-bg,#ffffff)] font-[var(--wire-font-sans,ui-sans-serif,system-ui,sans-serif)] text-[var(--wire-color-text,#0f172a)] antialiased {class} "
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
component ProductStatusBadge {
|
||||
props {
|
||||
label = "ProductStatus"
|
||||
variant = "default"
|
||||
label = "Available"
|
||||
status = ""
|
||||
class = ""
|
||||
}
|
||||
view { <span class="wire-badge wire-badge--{variant} wire-catalog-badge wire-catalog-badge--productstatusbadge {class}">{label}<slot /></span> }
|
||||
|
||||
view {
|
||||
<span
|
||||
class=' inline-flex w-max items-center gap-1.5 rounded-full border px-2.5 py-1 text-[0.7rem] font-bold leading-none {status === "warning" ? "border-[var(--wire-color-warning,#d97706)] bg-[var(--wire-color-warning-soft,#fffbeb)] text-[var(--wire-color-warning,#d97706)]" : status === "muted" ? "border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface-2,#f8fafc)] text-[var(--wire-color-muted,#64748b)]" : "border-[var(--wire-color-primary,#059669)] bg-[var(--wire-color-primary-soft,#ecfdf5)] text-[var(--wire-color-primary,#059669)]"} {class} '
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true"></span>
|
||||
{label}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,22 @@ component Button {
|
||||
icon = ""
|
||||
className = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<button type="{type}" disabled="{disabled || loading}" aria-busy="{loading}" class="wire-btn wire-btn--{variant} wire-btn--{size} {className}">
|
||||
{#if loading}<span class="wire-spinner" aria-hidden="true"></span>{/if}
|
||||
{#if icon && !loading}<span class="{icon} size-4" aria-hidden="true"></span>{/if}
|
||||
<button
|
||||
type="{type}"
|
||||
disabled="{disabled || loading}"
|
||||
aria-busy="{loading}"
|
||||
class="wire-btn wire-btn--{variant} wire-btn--{size} {className}"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="wire-spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
{#if icon && !loading}
|
||||
<span class="{icon} size-4" aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/ui — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -25,7 +25,7 @@
|
||||
"./ui.css": "./ui.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/uploader — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.74"
|
||||
"@wrnexus/core": "^0.2.79"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/validation — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -35,15 +35,15 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/authz": {
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts",
|
||||
},
|
||||
@@ -62,31 +62,32 @@
|
||||
},
|
||||
"packages/compiler": {
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/csr": {
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/dev-toolbar": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/pubsub": "workspace:*",
|
||||
"@wrnexus/router": "workspace:*",
|
||||
@@ -97,58 +98,66 @@
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/dev-toolbar": {
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.2.58",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.2",
|
||||
},
|
||||
},
|
||||
"packages/encryption": {
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/helpers": {
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/i18n": {
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/jwt": {
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/mobile": {
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/native": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/native": {
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/oauth": {
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/pubsub": {
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/queue": {
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/reactive": {
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/router": {
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
@@ -156,43 +165,43 @@
|
||||
},
|
||||
"packages/ssr": {
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/styles": {
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/uploader": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/test": {
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/tracking": {
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/uploader": {
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/validation": {
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.74",
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
@@ -445,6 +454,8 @@
|
||||
|
||||
"@wrnexus/dev-server": ["@wrnexus/dev-server@workspace:packages/dev-server"],
|
||||
|
||||
"@wrnexus/dev-toolbar": ["@wrnexus/dev-toolbar@workspace:packages/dev-toolbar"],
|
||||
|
||||
"@wrnexus/encryption": ["@wrnexus/encryption@workspace:packages/encryption"],
|
||||
|
||||
"@wrnexus/helpers": ["@wrnexus/helpers@workspace:packages/helpers"],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "wrnexus",
|
||||
"displayName": "WRNexus Language Support",
|
||||
"description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.",
|
||||
"version": "0.2.14",
|
||||
"version": "0.2.15",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -173,6 +173,71 @@ class Lexer {
|
||||
v += src[this.pos++];
|
||||
return v.trim();
|
||||
}
|
||||
readPropInitializer() {
|
||||
const { src } = this;
|
||||
let value = "";
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let quote = null;
|
||||
const beginsPropDeclaration = (position) => {
|
||||
let cursor = position;
|
||||
while (cursor < src.length && (src[cursor] === " " || src[cursor] === "\t"))
|
||||
cursor++;
|
||||
if (!isIdentStart(src[cursor] ?? ""))
|
||||
return false;
|
||||
cursor++;
|
||||
while (cursor < src.length && isIdentPart(src[cursor]))
|
||||
cursor++;
|
||||
while (cursor < src.length && (src[cursor] === " " || src[cursor] === "\t"))
|
||||
cursor++;
|
||||
return src[cursor] === "=" || src[cursor] === ":";
|
||||
};
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos];
|
||||
if (quote) {
|
||||
value += c;
|
||||
this.pos++;
|
||||
if (c === "\\" && this.pos < src.length)
|
||||
value += src[this.pos++];
|
||||
else if (c === quote)
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
value += c;
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
if (c === "[")
|
||||
square++;
|
||||
else if (c === "]" && square > 0)
|
||||
square--;
|
||||
else if (c === "{")
|
||||
brace++;
|
||||
else if (c === "}" && brace > 0)
|
||||
brace--;
|
||||
else if (c === "(")
|
||||
paren++;
|
||||
else if (c === ")" && paren > 0)
|
||||
paren--;
|
||||
const topLevel = square === 0 && brace === 0 && paren === 0;
|
||||
if (topLevel) {
|
||||
if (c === `
|
||||
` || c === "\r" || c === "}")
|
||||
break;
|
||||
if ((c === " " || c === "\t") && beginsPropDeclaration(this.pos))
|
||||
break;
|
||||
}
|
||||
value += c;
|
||||
this.pos++;
|
||||
}
|
||||
const result = value.trim();
|
||||
if (!result)
|
||||
throw new LexError(`Expected a prop initializer at offset ${this.pos}`);
|
||||
return result;
|
||||
}
|
||||
readTypeAnnotation() {
|
||||
const { src } = this;
|
||||
let value = "";
|
||||
@@ -471,7 +536,7 @@ function parse(source) {
|
||||
expect("eq");
|
||||
hasDefault = true;
|
||||
}
|
||||
const defaultValue = hasDefault ? lx.readToLineEnd() : "undefined";
|
||||
const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined";
|
||||
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
|
||||
}
|
||||
expect("rbrace");
|
||||
|
||||
@@ -17,6 +17,91 @@ const VOID_ELEMENTS = new Set([
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
function splitPropDeclarations(value) {
|
||||
const declarations = [];
|
||||
let start = 0;
|
||||
let index = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
|
||||
const isIdentifierStart = (character) => /[A-Za-z_]/.test(character || "");
|
||||
const isIdentifierPart = (character) => /[A-Za-z0-9_]/.test(character || "");
|
||||
|
||||
const beginsDeclaration = (position) => {
|
||||
let cursor = position;
|
||||
while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1;
|
||||
if (!isIdentifierStart(value[cursor])) return false;
|
||||
cursor += 1;
|
||||
while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1;
|
||||
while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1;
|
||||
return value[cursor] === "=" || value[cursor] === ":";
|
||||
};
|
||||
|
||||
while (index < value.length) {
|
||||
const character = value[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === quote) quote = null;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
quote = character;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "[") square += 1;
|
||||
else if (character === "]" && square > 0) square -= 1;
|
||||
else if (character === "{") brace += 1;
|
||||
else if (character === "}" && brace > 0) brace -= 1;
|
||||
else if (character === "(") paren += 1;
|
||||
else if (character === ")" && paren > 0) paren -= 1;
|
||||
|
||||
if (
|
||||
square === 0 &&
|
||||
brace === 0 &&
|
||||
paren === 0 &&
|
||||
/\s/.test(character) &&
|
||||
beginsDeclaration(index)
|
||||
) {
|
||||
const declaration = value.slice(start, index).trim();
|
||||
if (declaration) declarations.push(declaration);
|
||||
while (index < value.length && /\s/.test(value[index])) index += 1;
|
||||
start = index;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const declaration = value.slice(start).trim();
|
||||
if (declaration) declarations.push(declaration);
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function formatInlinePropsBlock(value, unit, depth) {
|
||||
const match = /^props\s*\{([\s\S]*)\}$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const declarations = splitPropDeclarations(match[1].trim());
|
||||
if (declarations.length === 0) {
|
||||
return [`${unit.repeat(depth)}props {`, `${unit.repeat(depth)}}`];
|
||||
}
|
||||
|
||||
return [
|
||||
`${unit.repeat(depth)}props {`,
|
||||
...declarations.map((declaration) => `${unit.repeat(depth + 1)}${declaration}`),
|
||||
`${unit.repeat(depth)}}`,
|
||||
];
|
||||
}
|
||||
|
||||
function findOpeningTagEnd(value) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
@@ -347,6 +432,13 @@ function formatWrn(source, options = {}) {
|
||||
index = collected.endIndex;
|
||||
}
|
||||
|
||||
const inlineProps = formatInlinePropsBlock(value, unit, codeDepth + htmlDepth);
|
||||
if (inlineProps) {
|
||||
output.push(...inlineProps);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const leadingClosingBraces = countLeadingClosingBraces(value);
|
||||
|
||||
const lineCodeDepth = Math.max(0, codeDepth - leadingClosingBraces);
|
||||
@@ -405,4 +497,5 @@ module.exports = {
|
||||
formatWrn,
|
||||
parseAttributes,
|
||||
parseOpeningTag,
|
||||
splitPropDeclarations,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const { formatWrn } = require("../src/formatter");
|
||||
|
||||
test("formats compact props blocks with one prop per line", () => {
|
||||
const source = `component Header {\nprops { eyebrow = "" title = "" description = "" align = "start" class = "" }\nview { <header>{title}</header> }\n}\n`;
|
||||
const expected = `component Header {\n props {\n eyebrow = ""\n title = ""\n description = ""\n align = "start"\n class = ""\n }\n view { <header>{title}</header> }\n}\n`;
|
||||
|
||||
assert.equal(formatWrn(source, { insertSpaces: true, tabSize: 2 }), expected);
|
||||
});
|
||||
|
||||
test("preserves nested prop defaults while formatting", () => {
|
||||
const source = `component Grid {\nprops { items = [{ label: "A" }, { label: "B" }] options = { gap: 4, dense: false } class = "" }\nview { <div/> }\n}\n`;
|
||||
const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2 });
|
||||
|
||||
assert.match(formatted, /items = \[\{ label: "A" \}, \{ label: "B" \}\]/);
|
||||
assert.match(formatted, /options = \{ gap: 4, dense: false \}/);
|
||||
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2 }), formatted);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -743,6 +743,46 @@ const MIGRATIONS: Migration[] = [
|
||||
// Language and shared UI behavior improve automatically after updating.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.75",
|
||||
id: "dev-toolbar",
|
||||
description: "Dev toolbar for optimization and developer.",
|
||||
apply() {
|
||||
// Language and shared UI behavior improve automatically after updating.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.76",
|
||||
id: "dev-toolbar-fix",
|
||||
description: "Dev toolbar for optimization and developer.",
|
||||
apply() {
|
||||
// Language and shared UI behavior improve automatically after updating.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.77",
|
||||
id: "ui-components-fix",
|
||||
description: "Dev toolbar for optimization and developer.",
|
||||
apply() {
|
||||
// Language and shared UI behavior improve automatically after updating.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.78",
|
||||
id: "ui-components-fix",
|
||||
description: "Dev toolbar for optimization and developer.",
|
||||
apply() {
|
||||
// Language and shared UI behavior improve automatically after updating.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.79",
|
||||
id: "ui-components-fix",
|
||||
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. */
|
||||
@@ -869,6 +909,20 @@ export function updateApp(appRoot: string, target: string, dryRun: boolean): Upd
|
||||
return { root: appRoot, packagePath: pkgPath, pkg };
|
||||
}
|
||||
|
||||
export function verificationCommands(scripts: Record<string, string>): string[][] {
|
||||
const commands: string[][] = [];
|
||||
|
||||
// Update migrations can rewrite TypeScript, JSON, and framework configuration
|
||||
// files. Normalize those edits with the project's own formatter before running
|
||||
// the read-only format check. This prevents a successful migration from failing
|
||||
// only because wrnexus.config.ts or another touched file needs Prettier output.
|
||||
if (scripts.format) commands.push(["run", "format"]);
|
||||
if (scripts.check) commands.push(["run", "check"]);
|
||||
if (scripts.build) commands.push(["run", "build"]);
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function verifyApp(app: UpdatedApp): boolean {
|
||||
const health = inspectProject(app.root);
|
||||
const failed = health.filter((check) => !check.ok && check.name !== "configuration");
|
||||
@@ -876,12 +930,11 @@ function verifyApp(app: UpdatedApp): boolean {
|
||||
for (const check of failed) console.error(` ✗ ${check.name}: ${check.detail}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const scripts = (app.pkg.scripts ?? {}) as Record<string, string>;
|
||||
const commands: string[][] = [];
|
||||
if (scripts.check) commands.push(["run", "check"]);
|
||||
if (scripts.build) commands.push(["run", "build"]);
|
||||
for (const args of commands) {
|
||||
console.log(`\n Verifying ${app.pkg.name ?? app.root}: bun ${args.join(" ")}…`);
|
||||
for (const args of verificationCommands(scripts)) {
|
||||
const action = args[1] === "format" ? "Formatting" : "Verifying";
|
||||
console.log(`\n ${action} ${app.pkg.name ?? app.root}: bun ${args.join(" ")}…`);
|
||||
const result = spawnSync(process.execPath, args, { cwd: app.root, stdio: "inherit" });
|
||||
if (result.status !== 0) return false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { updateApp } from "../src/update.ts";
|
||||
import { updateApp, verificationCommands } from "../src/update.ts";
|
||||
|
||||
test("updateApp migrates project files without marking an unverified update complete", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
@@ -150,3 +150,21 @@ test("0.2.20 migration repairs only the legacy generated recommendations layout"
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("update verification formats before checking and building", () => {
|
||||
expect(
|
||||
verificationCommands({
|
||||
format: "prettier . --write",
|
||||
check: "bun run lint && bun run format:check",
|
||||
build: "wrnexus build .",
|
||||
}),
|
||||
).toEqual([
|
||||
["run", "format"],
|
||||
["run", "check"],
|
||||
["run", "build"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("update verification skips unavailable scripts", () => {
|
||||
expect(verificationCommands({ check: "bun run lint" })).toEqual([["run", "check"]]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1144,6 +1144,50 @@ function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
||||
return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`;
|
||||
}
|
||||
|
||||
function unwrapDirectiveExpression(raw: string): string {
|
||||
const value = raw.trim();
|
||||
|
||||
if (!value.startsWith("{") || !value.endsWith("}")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let quote: '"' | "'" | "`" | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const char = value[index]!;
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{") depth++;
|
||||
if (char === "}") depth--;
|
||||
|
||||
if (depth === 0 && index < value.length - 1) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return depth === 0 ? value.slice(1, -1).trim() : value;
|
||||
}
|
||||
|
||||
/** Render a component view node into template-literal-ready source. */
|
||||
function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
if (node.type === "text") return compileText(node.value, ctx);
|
||||
@@ -1185,7 +1229,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
if (!attr.event && attr.name.startsWith("class:")) {
|
||||
conditionalClasses.push({
|
||||
className: attr.name.slice("class:".length),
|
||||
expression: attr.value,
|
||||
expression: unwrapDirectiveExpression(attr.value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ export function parse(source: string): PageAst {
|
||||
expect("eq");
|
||||
hasDefault = true;
|
||||
}
|
||||
const defaultValue = hasDefault ? lx.readToLineEnd() : "undefined";
|
||||
const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined";
|
||||
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
|
||||
}
|
||||
expect("rbrace");
|
||||
|
||||
@@ -138,6 +138,74 @@ export class Lexer {
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a prop default initializer. The initializer may contain nested arrays,
|
||||
* objects, calls, strings, or template literals. At top level it ends at a
|
||||
* newline, the closing brace of the props block, or the next inline prop
|
||||
* declaration (`name = ...` / `name: Type = ...`).
|
||||
*/
|
||||
readPropInitializer(): string {
|
||||
const { src } = this;
|
||||
while (this.pos < src.length && (src[this.pos] === " " || src[this.pos] === "\t")) {
|
||||
this.pos++;
|
||||
}
|
||||
|
||||
const start = this.pos;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let angle = 0;
|
||||
let quote: string | null = null;
|
||||
|
||||
const atTopLevel = () => square === 0 && brace === 0 && paren === 0 && angle === 0;
|
||||
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos]!;
|
||||
|
||||
if (quote) {
|
||||
this.pos++;
|
||||
if (c === "\\" && this.pos < src.length) {
|
||||
this.pos++;
|
||||
} else if (c === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (atTopLevel()) {
|
||||
if (c === "\n" || c === "\r" || c === "}") break;
|
||||
|
||||
if (c === " " || c === "\t") {
|
||||
let look = this.pos;
|
||||
while (look < src.length && (src[look] === " " || src[look] === "\t")) look++;
|
||||
const rest = src.slice(look);
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=\r\n{}]+)?\s*=/.test(rest)) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (c === "[") square++;
|
||||
else if (c === "]" && square > 0) square--;
|
||||
else if (c === "{") brace++;
|
||||
else if (c === "}" && brace > 0) brace--;
|
||||
else if (c === "(") paren++;
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
else if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
|
||||
this.pos++;
|
||||
}
|
||||
|
||||
const value = src.slice(start, this.pos).trim();
|
||||
if (!value) throw new LexError(`Expected a prop initializer at offset ${start}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Read the rest of the current line (used for `state x = <expr>`). */
|
||||
readToLineEnd(): string {
|
||||
const { src } = this;
|
||||
|
||||
@@ -788,6 +788,33 @@ component PageHeader {
|
||||
|
||||
expect(output).toContain("dark:bg-white/10");
|
||||
});
|
||||
test("class directives accept single-quoted brace expressions", () => {
|
||||
const output = generate(
|
||||
parse(`
|
||||
component MarketingSectionHeader {
|
||||
props {
|
||||
align = "start"
|
||||
}
|
||||
|
||||
view {
|
||||
<header
|
||||
class="max-w-3xl"
|
||||
class:mx-auto='{align === "center"}'
|
||||
class:text-center='{align === "center"}'
|
||||
>
|
||||
Header
|
||||
</header>
|
||||
}
|
||||
}
|
||||
`),
|
||||
);
|
||||
|
||||
expect(output).toContain('${(align === "center") ? " mx-auto" : ""}');
|
||||
expect(output).toContain('${(align === "center") ? " text-center" : ""}');
|
||||
expect(output).not.toContain('({align === "center"})');
|
||||
expect(output).toContain(""align === \\\\"center\\\\""");
|
||||
});
|
||||
|
||||
test("components support each blocks", () => {
|
||||
const output = generate(
|
||||
parse(`
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parse, type PropDecl } from "../src/parser.ts";
|
||||
|
||||
const compact = (source: string) =>
|
||||
parse(source).props.map(({ name, default: value }: PropDecl) => ({ name, value }));
|
||||
|
||||
describe("inline props", () => {
|
||||
test("parses multiple props declared on one line", () => {
|
||||
expect(
|
||||
compact(
|
||||
`component Example { props { eyebrow = "" title = "" description = "" align = "start" class = "" } view { <div></div> } }`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "eyebrow", value: '""' },
|
||||
{ name: "title", value: '""' },
|
||||
{ name: "description", value: '""' },
|
||||
{ name: "align", value: '"start"' },
|
||||
{ name: "class", value: '""' },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves nested object and array defaults", () => {
|
||||
expect(
|
||||
compact(
|
||||
`component Example { props { items = [{ label: "A" }, { label: "B" }] options = { gap: 4, dense: false } class = "" } view { <div></div> } }`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "items", value: '[{ label: "A" }, { label: "B" }]' },
|
||||
{ name: "options", value: "{ gap: 4, dense: false }" },
|
||||
{ name: "class", value: '""' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.74",
|
||||
"version": "0.2.79",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/dev-toolbar": "workspace:*",
|
||||
"@wrnexus/router": "workspace:*",
|
||||
"@wrnexus/ssr": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
|
||||
@@ -31,6 +31,17 @@ export class HmrHub {
|
||||
this.sockets.delete(ws);
|
||||
}
|
||||
|
||||
broadcastJson(message: unknown): void {
|
||||
const payload = JSON.stringify(message);
|
||||
for (const ws of this.sockets) {
|
||||
try {
|
||||
ws.send(payload);
|
||||
} catch {
|
||||
this.sockets.delete(ws);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(message: HmrMessage): void {
|
||||
const payload = JSON.stringify(message);
|
||||
for (const ws of this.sockets) {
|
||||
|
||||
@@ -31,6 +31,10 @@ import { startWatcher } from "./watch.ts";
|
||||
export { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
import { resetDevCache } from "./cache.ts";
|
||||
|
||||
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
|
||||
|
||||
import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
|
||||
|
||||
export interface ServeOptions {
|
||||
appDir: string;
|
||||
port?: number;
|
||||
@@ -62,6 +66,7 @@ export interface ServeOptions {
|
||||
storage?: StorageConfig;
|
||||
mobile?: MobileConfig;
|
||||
pwa?: PwaConfig | false;
|
||||
devToolbar?: boolean | DevToolbarConfig;
|
||||
}
|
||||
|
||||
export interface RunningServer {
|
||||
@@ -112,6 +117,40 @@ async function schemaRuntime(router: Router): Promise<string> {
|
||||
return renderSchemasScript(descriptors);
|
||||
}
|
||||
|
||||
function resolveDevToolbarConfig(
|
||||
mode: string,
|
||||
value: ServeOptions["devToolbar"],
|
||||
): DevToolbarConfig | null {
|
||||
if (mode !== "development" || value === false || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === true) {
|
||||
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> {
|
||||
const appDir = resolve(opts.appDir);
|
||||
const mode: Mode = opts.mode ?? "development";
|
||||
@@ -124,6 +163,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const styleEntry = opts.styleEntry ?? null;
|
||||
const appRoot = dirname(appDir);
|
||||
|
||||
const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar);
|
||||
|
||||
const devToolbarCollector: DevToolbarCollector | undefined = devToolbarConfig
|
||||
? createDevToolbarCollector()
|
||||
: undefined;
|
||||
|
||||
resetDevCache({
|
||||
rootDir: appRoot,
|
||||
enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1",
|
||||
@@ -187,6 +232,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
);
|
||||
|
||||
const hub = hmr ? new HmrHub() : undefined;
|
||||
const unsubscribeDevToolbar =
|
||||
devToolbarCollector && hub
|
||||
? devToolbarCollector.subscribe((issues) => {
|
||||
hub.broadcastJson({ channel: "toolbar", type: "toolbar:issues", issues });
|
||||
})
|
||||
: undefined;
|
||||
const middleware = middlewareLoader(router);
|
||||
|
||||
const runtimeDeps = {
|
||||
@@ -207,6 +258,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
security: opts.security,
|
||||
hub,
|
||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
||||
devToolbar:
|
||||
devToolbarConfig && devToolbarCollector
|
||||
? {
|
||||
config: devToolbarConfig,
|
||||
collector: devToolbarCollector,
|
||||
root: appRoot,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
const handlers = createHandlers(runtimeDeps);
|
||||
|
||||
@@ -252,8 +311,20 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
|
||||
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
|
||||
hub.reload();
|
||||
hub.broadcastJson({
|
||||
channel: "toolbar",
|
||||
type: "toolbar:scan",
|
||||
reason: "source-change",
|
||||
files,
|
||||
});
|
||||
};
|
||||
watcher = startWatcher({ appDir, hub, assets, onHotChange: hotUpdate });
|
||||
watcher = startWatcher({
|
||||
appDir,
|
||||
hub,
|
||||
assets,
|
||||
devToolbarCollector,
|
||||
onHotChange: hotUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
const boundPort = server.port ?? port;
|
||||
@@ -264,6 +335,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
router,
|
||||
stop: () => {
|
||||
watcher?.close();
|
||||
unsubscribeDevToolbar?.();
|
||||
server.stop();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,6 +54,14 @@ import {
|
||||
} from "@wrnexus/i18n";
|
||||
import { runMiddleware } from "./pipeline.ts";
|
||||
import type { HmrHub } from "./hmr.ts";
|
||||
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
|
||||
|
||||
import {
|
||||
createServerIssue,
|
||||
handleDevToolbarRoute,
|
||||
issueFromError,
|
||||
type DevToolbarCollector,
|
||||
} from "@wrnexus/dev-toolbar/server";
|
||||
|
||||
/** HTTP methods we recognize as API handler exports. */
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
|
||||
@@ -131,6 +139,32 @@ export interface RuntimeDeps {
|
||||
* bus (use the Redis pub/sub driver). Enables realtime across multiple apps.
|
||||
*/
|
||||
realtimeBus?: RealtimeBus;
|
||||
|
||||
devToolbar?: {
|
||||
config: DevToolbarConfig;
|
||||
collector: DevToolbarCollector;
|
||||
root: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DEV_TOOLBAR_SCRIPT =
|
||||
'<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) {
|
||||
@@ -429,8 +463,12 @@ export const HMR_CLIENT_JS = `
|
||||
};
|
||||
ws.onmessage = function (e) {
|
||||
var msg; try { msg = JSON.parse(e.data); } catch (_) { return; }
|
||||
if (msg.type === "css") swapCss(msg.version);
|
||||
else if (msg.type === "reload") requestSync();
|
||||
if (msg.channel === "toolbar") {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:toolbar-message", { detail: msg }));
|
||||
} else if (msg.type === "css") {
|
||||
swapCss(msg.version);
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: msg }));
|
||||
} else if (msg.type === "reload") requestSync();
|
||||
else if (msg.type === "html") applyHtml(msg.html);
|
||||
else if (msg.type === "error") console.error("[wrnexus] HMR update failed:", msg.message);
|
||||
};
|
||||
@@ -479,6 +517,7 @@ export const HMR_CLIENT_JS = `
|
||||
// their reactive scopes (and any browser-side API fetches) in place.
|
||||
if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document);
|
||||
if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document);
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: { type: "html" } }));
|
||||
}
|
||||
|
||||
// Minimal index-based DOM morph: preserve matching nodes (keeps state/focus),
|
||||
@@ -717,6 +756,17 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
return secure(new Response("Payload Too Large", { status: 413 }));
|
||||
}
|
||||
|
||||
if (deps.devToolbar) {
|
||||
const toolbarResponse = await handleDevToolbarRoute(req, {
|
||||
mode,
|
||||
root: deps.devToolbar.root,
|
||||
collector: deps.devToolbar.collector,
|
||||
editor: deps.devToolbar.config.editor,
|
||||
allowOpenEditor: deps.devToolbar.config.openEditor !== false,
|
||||
});
|
||||
if (toolbarResponse) return secure(toolbarResponse);
|
||||
}
|
||||
|
||||
// --- HMR socket (dev only): upgrade before anything else. ---
|
||||
if (hmr && url.pathname === "/__wrnexus/hmr") {
|
||||
if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin());
|
||||
@@ -795,6 +845,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
console.error(
|
||||
`[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`,
|
||||
);
|
||||
deps.devToolbar?.collector.add(
|
||||
issueFromError(err, {
|
||||
ruleId: "server/request-error",
|
||||
category: "server",
|
||||
title: "Request processing failed",
|
||||
pathname: url.pathname,
|
||||
}),
|
||||
);
|
||||
const response = secure(renderError(err, mode));
|
||||
// Gateway-managed production apps bind to loopback. Carry a bounded,
|
||||
// encoded diagnostic to the parent gateway so centralized log collectors
|
||||
@@ -1004,6 +1062,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
result += await renderComponents(rendered, translate, depth + 1);
|
||||
} catch (err) {
|
||||
console.error(`[wrnexus] component '${name}' failed to render`, err);
|
||||
deps.devToolbar?.collector.add(
|
||||
issueFromError(err, {
|
||||
ruleId: "server/component-render-error",
|
||||
category: "server",
|
||||
title: `Component '${name}' failed to render`,
|
||||
source: { file: component.file, component: name },
|
||||
}),
|
||||
);
|
||||
result += body.slice(tagStart, end);
|
||||
}
|
||||
}
|
||||
@@ -1018,6 +1084,20 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
);
|
||||
const matched = router.matchPage(ctx.url.pathname);
|
||||
if (!matched) {
|
||||
if (deps.devToolbar && shouldReportNotFound(ctx.url.pathname)) {
|
||||
deps.devToolbar.collector.add(
|
||||
createServerIssue({
|
||||
ruleId: "routing/not-found",
|
||||
category: "routing",
|
||||
severity: "warning",
|
||||
title: "Route not found",
|
||||
message: `No page route matched ${ctx.url.pathname}.`,
|
||||
pathname: ctx.url.pathname,
|
||||
recommendation:
|
||||
"Verify the URL, page filename, dynamic route parameters, and route casing.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const response = renderNotFound();
|
||||
if (!isMobileRequest) return response;
|
||||
const headers = new Headers(response.headers);
|
||||
@@ -1065,6 +1145,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wrnexus] layout '${layoutName}' failed to render`, err);
|
||||
deps.devToolbar?.collector.add(
|
||||
issueFromError(err, {
|
||||
ruleId: "server/layout-render-error",
|
||||
category: "server",
|
||||
title: `Layout '${layoutName}' failed to render`,
|
||||
pathname: ctx.url.pathname,
|
||||
source: { file: layout.file },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,7 +1191,13 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n "),
|
||||
extraBody: hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : undefined,
|
||||
extraBody:
|
||||
[
|
||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n") || undefined,
|
||||
htmlAttrs,
|
||||
});
|
||||
// Conditional GET: hash the page CONTENT (`body`), not the assembled shell —
|
||||
@@ -1121,6 +1216,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
etag: tag,
|
||||
"cache-control": "private, no-cache",
|
||||
...(shouldEnableDevToolbar(mode, deps)
|
||||
? {
|
||||
"x-wrnexus-dev-toolbar": "enabled",
|
||||
"x-wrnexus-route": matched.route.raw,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ const server = await startServer({
|
||||
storage: config.storage,
|
||||
mobile: config.mobile,
|
||||
pwa: config.pwa,
|
||||
devToolbar: config.devToolbar,
|
||||
});
|
||||
const r = server.router;
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import type { HmrHub } from "./hmr.ts";
|
||||
import type { DevAssetServer } from "./assets.ts";
|
||||
import type { DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
|
||||
|
||||
export interface WatchOptions {
|
||||
appDir: string;
|
||||
hub: HmrHub;
|
||||
assets: DevAssetServer;
|
||||
devToolbarCollector?: DevToolbarCollector;
|
||||
/** Called with changed app-relative files that need an in-process hot update. */
|
||||
onHotChange: (files: string[]) => void | Promise<void>;
|
||||
}
|
||||
@@ -50,6 +52,7 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
||||
pending.clear();
|
||||
const files = [...pendingFiles];
|
||||
pendingFiles.clear();
|
||||
for (const file of files) opts.devToolbarCollector?.clear(file);
|
||||
void Promise.resolve(opts.onHotChange(files)).catch((error) => {
|
||||
console.error("[wrnexus] hot update failed", error);
|
||||
});
|
||||
@@ -58,6 +61,12 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
||||
if (pending.has("css")) {
|
||||
assets.invalidateCss();
|
||||
hub.css();
|
||||
hub.broadcastJson({
|
||||
channel: "toolbar",
|
||||
type: "toolbar:scan",
|
||||
reason: "styles-change",
|
||||
files: [...pendingFiles],
|
||||
});
|
||||
}
|
||||
pending.clear();
|
||||
pendingFiles.clear();
|
||||
|
||||
@@ -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 } }));
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.2.79",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DEV_TOOLBAR_RUNTIME } from "./runtime.ts";
|
||||
export { DEV_TOOLBAR_CSS } from "./styles.ts";
|
||||
@@ -0,0 +1,56 @@
|
||||
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:"", severity:"all", 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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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=>{const severityMatches=state.severity==="all"||x.severity===state.severity;const textMatches=!state.search||[x.title,x.message,x.ruleId,x.category,x.severity,x.source?.file].join(" ").toLowerCase().includes(state.search);return severityMatches&&textMatches;});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;root.querySelectorAll("[data-filter]").forEach(button=>{const active=button.dataset.filter===state.severity;button.classList.toggle("active",active);button.setAttribute("aria-pressed",String(active));});meta.textContent=location.pathname+" · "+state.issues.length+" issues"+(state.severity!=="all"?" · "+state.severity:"");if(!filtered.length){list.innerHTML='<div class="wrn-empty">No issues match the current filters.</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.severity)+' · '+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()};
|
||||
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;state.severity="all";panel.classList.toggle("open",state.open);render()}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=[];state.severity="all";state.search="";search.value="";render()}if(button.dataset.filter){state.severity=state.severity===button.dataset.filter?"all":button.dataset.filter;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(scan,50)});addEventListener("wrnexus:hmr",()=>{if(state.config.scanOnHmr)setTimeout(scan,100)});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(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-count.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.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}}
|
||||
`;
|
||||
@@ -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.',
|
||||
}),
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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 70–170 characters.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
if (!document.querySelector('meta[name="viewport"]'))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/viewport-missing",
|
||||
category: "seo",
|
||||
severity: "error",
|
||||
title: "Viewport meta tag is missing",
|
||||
message: "The page may render incorrectly on mobile devices.",
|
||||
recommendation: "Add width=device-width, initial-scale=1.",
|
||||
}),
|
||||
);
|
||||
if (!document.querySelector('link[rel="canonical"]'))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/canonical-missing",
|
||||
category: "seo",
|
||||
severity: "suggestion",
|
||||
title: "Canonical URL is missing",
|
||||
message: "Search engines have no explicit preferred URL for this page.",
|
||||
recommendation: "Add a canonical link for public indexable pages.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
const h1s = document.querySelectorAll("h1");
|
||||
if (h1s.length === 0)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/h1-missing",
|
||||
category: "seo",
|
||||
severity: "warning",
|
||||
title: "Page has no h1 heading",
|
||||
message: "The page lacks a clear primary heading.",
|
||||
recommendation: "Add one descriptive h1.",
|
||||
}),
|
||||
);
|
||||
if (h1s.length > 1)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/multiple-h1",
|
||||
category: "seo",
|
||||
severity: "suggestion",
|
||||
title: "Page has multiple h1 headings",
|
||||
message: `Found ${h1s.length} h1 elements.`,
|
||||
recommendation:
|
||||
"Use one clear primary page heading unless multiple h1 elements are intentional.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user