release: WRNexusJS 0.5.10

This commit is contained in:
2026-07-30 13:36:29 +05:30
parent 8fc6f15402
commit d1b0c55b53
159 changed files with 7509 additions and 604 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,18 +28,18 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
"@wrnexus/core": "^0.5.1",
"@wrnexus/router": "^0.5.1",
"@wrnexus/csr": "^0.5.1",
"@wrnexus/compiler": "^0.5.1",
"@wrnexus/styles": "^0.5.1",
"@wrnexus/dev-server": "^0.5.1",
"@wrnexus/ui": "^0.5.1",
"@wrnexus/validation": "^0.5.1",
"@wrnexus/i18n": "^0.5.1",
"@wrnexus/db": "^0.5.1",
"@wrnexus/plugin": "^0.5.1",
"@wrnexus/syntax": "^0.5.1"
"@wrnexus/core": "^0.5.10",
"@wrnexus/router": "^0.5.10",
"@wrnexus/csr": "^0.5.10",
"@wrnexus/compiler": "^0.5.10",
"@wrnexus/styles": "^0.5.10",
"@wrnexus/dev-server": "^0.5.10",
"@wrnexus/ui": "^0.5.10",
"@wrnexus/validation": "^0.5.10",
"@wrnexus/i18n": "^0.5.10",
"@wrnexus/db": "^0.5.10",
"@wrnexus/plugin": "^0.5.10",
"@wrnexus/syntax": "^0.5.10"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/syntax": "^0.5.1"
"@wrnexus/syntax": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.5.1"
"@wrnexus/core": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,20 +25,20 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.5.1",
"@wrnexus/dev-toolbar": "^0.5.1",
"@wrnexus/router": "^0.5.1",
"@wrnexus/ssr": "^0.5.1",
"@wrnexus/csr": "^0.5.1",
"@wrnexus/compiler": "^0.5.1",
"@wrnexus/styles": "^0.5.1",
"@wrnexus/ui": "^0.5.1",
"@wrnexus/validation": "^0.5.1",
"@wrnexus/i18n": "^0.5.1",
"@wrnexus/db": "^0.5.1",
"@wrnexus/pubsub": "^0.5.1",
"@wrnexus/uploader": "^0.5.1",
"@wrnexus/plugin": "^0.5.1"
"@wrnexus/core": "^0.5.10",
"@wrnexus/dev-toolbar": "^0.5.10",
"@wrnexus/router": "^0.5.10",
"@wrnexus/ssr": "^0.5.10",
"@wrnexus/csr": "^0.5.10",
"@wrnexus/compiler": "^0.5.10",
"@wrnexus/styles": "^0.5.10",
"@wrnexus/ui": "^0.5.10",
"@wrnexus/validation": "^0.5.10",
"@wrnexus/i18n": "^0.5.10",
"@wrnexus/db": "^0.5.10",
"@wrnexus/pubsub": "^0.5.10",
"@wrnexus/uploader": "^0.5.10",
"@wrnexus/plugin": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/dev-toolbar — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.5.1"
"@wrnexus/core": "^0.5.10"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.5.1"
"@wrnexus/core": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/native": "^0.5.1"
"@wrnexus/native": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/plugin — part of the WrNexus framework.",
"license": "MIT",
@@ -33,7 +33,7 @@
}
},
"dependencies": {
"@wrnexus/syntax": "^0.5.1"
"@wrnexus/syntax": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
"@wrnexus/compiler": "^0.5.1",
"@wrnexus/core": "^0.5.1"
"@wrnexus/compiler": "^0.5.10",
"@wrnexus/core": "^0.5.10"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.5.1"
"@wrnexus/core": "^0.5.10"
},
"files": [
"dist"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,9 +21,9 @@
}
},
"dependencies": {
"@wrnexus/uploader": "^0.5.1",
"@wrnexus/core": "^0.5.1",
"@wrnexus/plugin": "^0.5.1"
"@wrnexus/uploader": "^0.5.10",
"@wrnexus/core": "^0.5.10",
"@wrnexus/plugin": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/syntax — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
+57 -10
View File
@@ -1,6 +1,6 @@
# WRNexus UI component reference
This reference is generated from the 85 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
This reference is generated from the 90 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
## Advanced-forms
@@ -330,15 +330,35 @@ Theme-aware, responsive tree view component.
- Slots: `default`
- Events: `select`, `toggle`, `expand`, `collapse`
## Blocks
### PortalDashboard
Responsive portal dashboard with metrics, actions, tasks, and updates.
- Mount: `data-component="PortalDashboard"`
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Overview"`, `eyebrowKey: string = ""`, `title: string = "Dashboard"`, `titleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `userName: string = ""`, `metrics: string = []`, `actions: string = []`, `updates: string = []`, `tasks: string = []`, `class: string = ""`
- Slots: `hero-action`
- Events: `action`, `navigate`
## Forms
### AuthForm
Reusable authentication form for sign-in, registration, recovery, reset, and MFA.
- Mount: `data-component="AuthForm"`
- Props: `size: string = "default"`, `color: string = "primary"`, `mode: string = "sign-in"`, `action: string = "/api/auth/login"`, `method: string = "post"`, `title: string = "Sign in"`, `description: string = ""`, `returnTo: string = ""`, `schema: string = ""`, `showRemember: boolean = true`, `showName: boolean = true`, `submitLabel: string = "Continue"`, `class: string = ""`
- Slots: `default`
- Events: `submit`, `change`, `input`, `focus`, `blur`
### Checkbox
Theme-aware, responsive checkbox component.
- Mount: `data-component="Checkbox"`
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Checkbox"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `values: string = []`, `options: string = []`, `checked: boolean = false`, `indeterminate: boolean = false`, `orientation: string = "vertical"`, `card: boolean = false`, `rightAligned: boolean = false`, `list: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
- Slots: None
- Slots: `default`
- Events: `input`, `change`, `focus`, `blur`, `invalid`
### ColorPicker
@@ -543,6 +563,15 @@ Theme-aware, responsive wysiwyg editor component.
## Layout
### AuthSplitLayout
Responsive 50/50 authentication layout with content and form regions.
- Mount: `data-component="AuthSplitLayout"`
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Secure identity"`, `title: string = "Welcome back"`, `description: string = ""`, `brand: string = "Police Management System"`, `features: string = []`, `class: string = ""`
- Slots: `aside-extra`, `form`
- Events: None
### Columns
Theme-aware, responsive columns component.
@@ -644,6 +673,15 @@ Theme-aware, responsive breadcrumb component.
- Slots: `default`
- Events: `navigate`, `click`
### Footer
Responsive application footer with structured links and pre/post content slots.
- Mount: `data-component="Footer"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Footer navigation"`, `items: string = []`, `columns: number = 3`, `maxWidth: string = "compact"`, `copyright: string = ""`, `class: string = ""`
- Slots: `pre-footer`, `post-footer`, `copyright-left`, `copyright-right`
- Events: `select`, `action`
### MegaMenu
Theme-aware, responsive mega menu component.
@@ -667,9 +705,9 @@ Theme-aware, responsive nav component.
Theme-aware, responsive navbar component.
- Mount: `data-component="Navbar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Navbar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
- Slots: `default`
- Events: `toggle`, `open`, `close`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: string = {}`, `items: string = []`, `actions: string = []`, `active: string = ""`, `sticky: boolean = false`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `class: string = ""`
- Slots: `topbar`, `actions`
- Events: `toggle`, `open`, `close`, `select`, `action`
### Pagination
@@ -680,6 +718,15 @@ Theme-aware, responsive pagination component.
- Slots: `default`
- Events: `change`, `previous`, `next`
### PreferenceSwitcher
Accessible theme, accent color, and language preference controls.
- Mount: `data-component="PreferenceSwitcher"`
- Props: `size: string = "default"`, `color: string = "primary"`, `themeLabel: string = "Theme"`, `colorLabel: string = "Accent color"`, `languageLabel: string = "Language"`, `languages: string = [`, `colors: string = [`, `compact: boolean = true`, `class: string = ""`
- Slots: None
- Events: `theme`, `color`, `language`
### Scrollspy
Theme-aware, responsive scrollspy component.
@@ -694,9 +741,9 @@ Theme-aware, responsive scrollspy component.
Theme-aware, responsive sidebar component.
- Mount: `data-component="Sidebar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""`
- Slots: `default`
- Events: `toggle`, `open`, `close`
- Events: `toggle`, `open`, `close`, `select`
### Stepper
@@ -741,9 +788,9 @@ Theme-aware, responsive drawer component.
Theme-aware, responsive dropdown component.
- Mount: `data-component="Dropdown"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Dropdown"`, `description: string = ""`, `open: boolean = false`, `placement: string = "bottom"`, `closeLabel: string = "Close"`, `class: string = ""`
- Slots: `default`
- Events: `open`, `close`, `select`, `change`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Open menu"`, `items: string = []`, `placement: string = "end"`, `class: string = ""`
- Slots: `trigger`, `header`, `footer`
- Events: `toggle`, `open`, `close`, `select`
### Modal
+25
View File
@@ -191,6 +191,11 @@
"category": "navigation",
"purpose": "Theme-aware, responsive navbar component."
},
{
"name": "Footer",
"category": "navigation",
"purpose": "Responsive application footer with structured links and pre/post content slots."
},
{
"name": "MegaMenu",
"category": "navigation",
@@ -429,6 +434,26 @@
"name": "WysiwygEditor",
"category": "integrations",
"purpose": "Theme-aware, responsive wysiwyg editor component."
},
{
"name": "PreferenceSwitcher",
"category": "navigation",
"purpose": "Accessible theme, accent color, and language preference controls."
},
{
"name": "PortalDashboard",
"category": "blocks",
"purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates."
},
{
"name": "AuthSplitLayout",
"category": "layout",
"purpose": "Responsive 50/50 authentication layout with content and form regions."
},
{
"name": "AuthForm",
"category": "forms",
"purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA."
}
]
}
+424 -27
View File
@@ -1,6 +1,6 @@
{
"generatedFrom": "packages/ui/components/*.wrn",
"count": 85,
"count": 90,
"components": [
{
"name": "Accordion",
@@ -675,6 +675,154 @@
"events": ["dismiss", "action"],
"source": "components/alert.wrn"
},
{
"name": "AuthForm",
"mount": "AuthForm",
"category": "forms",
"purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "mode",
"type": "string",
"required": false,
"default": "\"sign-in\""
},
{
"name": "action",
"type": "string",
"required": false,
"default": "\"/api/auth/login\""
},
{
"name": "method",
"type": "string",
"required": false,
"default": "\"post\""
},
{
"name": "title",
"type": "string",
"required": false,
"default": "\"Sign in\""
},
{
"name": "description",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "returnTo",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "schema",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "showRemember",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "showName",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "submitLabel",
"type": "string",
"required": false,
"default": "\"Continue\""
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["default"],
"events": ["submit", "change", "input", "focus", "blur"],
"source": "components/AuthForm.wrn"
},
{
"name": "AuthSplitLayout",
"mount": "AuthSplitLayout",
"category": "layout",
"purpose": "Responsive 50/50 authentication layout with content and form regions.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "eyebrow",
"type": "string",
"required": false,
"default": "\"Secure identity\""
},
{
"name": "title",
"type": "string",
"required": false,
"default": "\"Welcome back\""
},
{
"name": "description",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "brand",
"type": "string",
"required": false,
"default": "\"Police Management System\""
},
{
"name": "features",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["aside-extra", "form"],
"events": [],
"source": "components/AuthSplitLayout.wrn"
},
{
"name": "Avatar",
"mount": "Avatar",
@@ -2062,7 +2210,7 @@
"default": "\"\""
}
],
"slots": [],
"slots": ["default"],
"events": ["input", "change", "focus", "blur", "invalid"],
"source": "components/Checkbox.wrn"
},
@@ -3436,34 +3584,22 @@
"default": "\"primary\""
},
{
"name": "title",
"name": "label",
"type": "string",
"required": false,
"default": "\"Dropdown\""
"default": "\"Open menu\""
},
{
"name": "description",
"name": "items",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "open",
"type": "boolean",
"required": false,
"default": "false"
"default": "[]"
},
{
"name": "placement",
"type": "string",
"required": false,
"default": "\"bottom\""
},
{
"name": "closeLabel",
"type": "string",
"required": false,
"default": "\"Close\""
"default": "\"end\""
},
{
"name": "class",
@@ -3472,8 +3608,8 @@
"default": "\"\""
}
],
"slots": ["default"],
"events": ["open", "close", "select", "change"],
"slots": ["trigger", "header", "footer"],
"events": ["toggle", "open", "close", "select"],
"source": "components/Dropdown.wrn"
},
{
@@ -3755,6 +3891,65 @@
"events": ["cancel", "retry", "complete"],
"source": "components/FileUploadProgress.wrn"
},
{
"name": "Footer",
"mount": "Footer",
"category": "navigation",
"purpose": "Responsive application footer with structured links and pre/post content slots.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "label",
"type": "string",
"required": false,
"default": "\"Footer navigation\""
},
{
"name": "items",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "columns",
"type": "number",
"required": false,
"default": "3"
},
{
"name": "maxWidth",
"type": "string",
"required": false,
"default": "\"compact\""
},
{
"name": "copyright",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["pre-footer", "post-footer", "copyright-left", "copyright-right"],
"events": ["select", "action"],
"source": "components/Footer.wrn"
},
{
"name": "Grid",
"mount": "Grid",
@@ -5039,7 +5234,19 @@
"name": "label",
"type": "string",
"required": false,
"default": "\"Navbar\""
"default": "\"Primary navigation\""
},
{
"name": "topbarLabel",
"type": "string",
"required": false,
"default": "\"Utility navigation\""
},
{
"name": "brand",
"type": "string",
"required": false,
"default": "{}"
},
{
"name": "items",
@@ -5047,6 +5254,12 @@
"required": false,
"default": "[]"
},
{
"name": "actions",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "active",
"type": "string",
@@ -5054,10 +5267,28 @@
"default": "\"\""
},
{
"name": "orientation",
"name": "sticky",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "openOnHover",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "maxWidth",
"type": "string",
"required": false,
"default": "\"horizontal\""
"default": "\"full\""
},
{
"name": "mobileLabel",
"type": "string",
"required": false,
"default": "\"Toggle navigation\""
},
{
"name": "class",
@@ -5066,8 +5297,8 @@
"default": "\"\""
}
],
"slots": ["default"],
"events": ["toggle", "open", "close"],
"slots": ["topbar", "actions"],
"events": ["toggle", "open", "close", "select", "action"],
"source": "components/Navbar.wrn"
},
{
@@ -5349,6 +5580,166 @@
"events": ["open", "close"],
"source": "components/Popover.wrn"
},
{
"name": "PortalDashboard",
"mount": "PortalDashboard",
"category": "blocks",
"purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "eyebrow",
"type": "string",
"required": false,
"default": "\"Overview\""
},
{
"name": "eyebrowKey",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "title",
"type": "string",
"required": false,
"default": "\"Dashboard\""
},
{
"name": "titleKey",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "description",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "descriptionKey",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "userName",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "metrics",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "actions",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "updates",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "tasks",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["hero-action"],
"events": ["action", "navigate"],
"source": "components/PortalDashboard.wrn"
},
{
"name": "PreferenceSwitcher",
"mount": "PreferenceSwitcher",
"category": "navigation",
"purpose": "Accessible theme, accent color, and language preference controls.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "themeLabel",
"type": "string",
"required": false,
"default": "\"Theme\""
},
{
"name": "colorLabel",
"type": "string",
"required": false,
"default": "\"Accent color\""
},
{
"name": "languageLabel",
"type": "string",
"required": false,
"default": "\"Language\""
},
{
"name": "languages",
"type": "string",
"required": false,
"default": "["
},
{
"name": "colors",
"type": "string",
"required": false,
"default": "["
},
{
"name": "compact",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": [],
"events": ["theme", "color", "language"],
"source": "components/PreferenceSwitcher.wrn"
},
{
"name": "Progress",
"mount": "Progress",
@@ -6110,6 +6501,12 @@
"required": false,
"default": "\"horizontal\""
},
{
"name": "mobileLabel",
"type": "string",
"required": false,
"default": "\"Open navigation\""
},
{
"name": "class",
"type": "string",
@@ -6118,7 +6515,7 @@
}
],
"slots": ["default"],
"events": ["toggle", "open", "close"],
"events": ["toggle", "open", "close", "select"],
"source": "components/Sidebar.wrn"
},
{
+40 -10
View File
@@ -53,7 +53,9 @@ component AvatarGroup {
role="group"
aria-label="Avatar group"
>
<div class="wire-next__avatar-group-members">
<div
class="wire-next__avatar-group-members"
>
{#each visibleMembers() as item}
<span
class="wire-next__avatar-group-member"
@@ -64,18 +66,35 @@ component AvatarGroup {
tabindex="{showTooltips && (item.tooltip || item.name) ? '0' : '-1'}"
aria-label="{item.name || item.alt || item.initials || 'Group member'}"
>
<span class="wire-next__avatar-group-avatar">
<span
class="wire-next__avatar-group-avatar"
>
{#if item.src}
<img src="{item.src}" alt="{item.alt || item.name || ''}" loading="lazy" />
<img
src="{item.src}"
alt="{item.alt || item.name || ''}"
loading="lazy"
/>
{:else if item.initials}
<span aria-hidden="true">{item.initials}</span>
<span
aria-hidden="true"
>
{item.initials}
</span>
{:else}
<span class="icon-[lucide--user-round]" aria-hidden="true"></span>
<span
class="icon-[lucide--user-round]"
aria-hidden="true"
>
</span>
{/if}
</span>
{#if showTooltips && (item.tooltip || item.name)}
<span class="wire-next__avatar-group-tooltip" role="tooltip">
<span
class="wire-next__avatar-group-tooltip"
role="tooltip"
>
{item.tooltip || item.name}
</span>
{/if}
@@ -83,7 +102,9 @@ component AvatarGroup {
{/each}
{#if hiddenMembers().length > 0}
<span class="wire-next__avatar-group-overflow">
<span
class="wire-next__avatar-group-overflow"
>
<button
type="button"
class="wire-next__avatar-group-overflow-button"
@@ -101,10 +122,19 @@ component AvatarGroup {
aria-hidden="{overflowOpen ? 'false' : 'true'}"
>
{#each hiddenMembers() as item}
<span class="wire-next__avatar-group-menu-item" role="menuitem">
<span class="wire-next__avatar-group-menu-avatar">
<span
class="wire-next__avatar-group-menu-item"
role="menuitem"
>
<span
class="wire-next__avatar-group-menu-avatar"
>
{#if item.src}
<img src="{item.src}" alt="" loading="lazy" />
<img
src="{item.src}"
alt=""
loading="lazy"
/>
{:else}
<span>{item.initials || '?'}</span>
{/if}
+41 -11
View File
@@ -1,23 +1,53 @@
component Dropdown {
props {
@event toggle = function
@event open = function
@event close = function
@event select = function
@event change = function
size = "default"
color = "primary"
title = "Dropdown"
description = ""
open = false
placement = "bottom"
closeLabel = "Close"
label = "Open menu"
items = []
placement = "end"
class = ""
}
functions {
function toggleMenu(event) {
$emit("toggle", { open: event.currentTarget.open })
$emit(event.currentTarget.open ? "open" : "close", { source: "trigger" })
}
function selectItem(item) {
$emit("select", { item: item, value: item.value || "" })
}
}
view {
<div data-show="{open}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--dropdown wire-next--placement-{placement} {class}" role="region" aria-modal="false" aria-label="{title}">
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header>
{#if description}<p>{description}</p>{/if}
<slot />
</div>
<details class="wire-dropdown wire-dropdown--{placement} wire-next--color-{color} wire-next--size-{size} {class}" data-wrn-dropdown @toggle="toggleMenu($event)">
<summary aria-label="{label}">
<slot name="trigger" />
<span class="wire-dropdown__chevron icon-[lucide--chevron-down]" aria-hidden="true"></span>
</summary>
<div class="wire-dropdown__panel" role="menu" aria-label="{label}">
<slot name="header" />
{#each items as item}
{#if item.type === "divider"}
<hr />
{:else}
{#if item.type === "header"}
<span class="wire-dropdown__heading">{item.label}</span>
{:else}
<a class="wire-dropdown__item {item.danger ? 'wire-dropdown__item--danger' : ''}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" role="menuitem" @click="selectItem(item)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span><strong>{item.label}</strong>{#if item.description}<small>{item.description}</small>{/if}</span>
{#if item.external}<span class="icon-[lucide--arrow-up-right]" aria-hidden="true"></span>{/if}
</a>
{/if}
{/if}
{/each}
<slot name="footer" />
</div>
</details>
}
}
+126 -6
View File
@@ -3,18 +3,138 @@ component Navbar {
@event toggle = function
@event open = function
@event close = function
@event select = function
@event action = function
size = "default"
color = "primary"
label = "Navbar"
label = "Primary navigation"
topbarLabel = "Utility navigation"
brand = {}
items = []
actions = []
active = ""
orientation = "horizontal"
sticky = false
openOnHover = false
maxWidth = "full"
mobileLabel = "Toggle navigation"
class = ""
}
state mobileOpen = false
functions {
function toggleNavigation() {
mobileOpen = !mobileOpen
$emit("toggle", { open: mobileOpen })
$emit(mobileOpen ? "open" : "close", { source: "mobile" })
}
function selectItem(item, level) {
mobileOpen = false
$emit("select", { item: item, value: item.value || "", level: level })
}
function selectAction(item) {
mobileOpen = false
$emit("action", { item: item, value: item.value || "" })
}
function isItemActive(item) {
if (!item) return false
if (item.value && item.value === active) return true
if (!item.children || !item.children.length) return false
return item.children.some(function (child) {
return isItemActive(child)
})
}
function toggleDropdown(event, item) {
$emit(event.currentTarget.open ? "open" : "close", {
source: "menu",
item: item
})
}
}
view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--navbar wire-next--{orientation} {class}" aria-label="{label}">
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
<slot />
</nav>
<header class="wire-navbar wire-navbar--width-{maxWidth} wire-next--color-{color} wire-next--size-{size} {sticky ? 'wire-navbar--sticky' : ''} {class}" data-wrn-navbar data-open-on-hover="{openOnHover}">
<div class="wire-navbar__topbar" aria-label="{topbarLabel}">
<slot name="topbar" />
</div>
<div class="wire-navbar__main">
<a class="wire-navbar__brand" href="{brand.href || '/'}" aria-label="{brand.ariaLabel || brand.label || label}">
{#if brand.logo}
<img class="wire-navbar__brand-logo" src="{brand.logo}" alt="{brand.logoAlt || brand.label || ''}" width="{brand.logoWidth || ''}" height="{brand.logoHeight || ''}" />
{/if}
{#if brand.icon}
<span class="wire-navbar__brand-icon {brand.icon}" aria-hidden="true"></span>
{/if}
{#if brand.label || brand.description}
<span class="wire-navbar__brand-copy">
{#if brand.label}<strong>{brand.label}</strong>{/if}
{#if brand.description}<small>{brand.description}</small>{/if}
</span>
{/if}
</a>
<button type="button" class="wire-navbar__toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleNavigation()">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
<nav class="wire-navbar__menus" aria-label="{label}">
{#each items as item}
{#if item.children && item.children.length}
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown($event, item)">
<summary aria-current="{isItemActive(item) ? 'page' : ''}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
<span class="wire-navbar__chevron" aria-hidden="true"></span>
</summary>
<div class="wire-navbar__panel wire-navbar__panel--columns-{item.columns || 1}">
{#if item.description}<p class="wire-navbar__panel-intro">{item.description}</p>{/if}
{#each item.children as child}
<div class="wire-navbar__group">
{#if child.children && child.children.length}
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
{#if child.description}<small>{child.description}</small>{/if}
{#each child.children as nested}
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)">
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
</a>
{/each}
{:else}
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
</a>
{/if}
</div>
{/each}
</div>
</details>
{:else}
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
{/if}
{/each}
</nav>
<div class="wire-navbar__actions">
{#each actions as item}
<a class="wire-navbar__action wire-navbar__action--{item.variant || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectAction(item)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
{/each}
<slot name="actions" />
</div>
</div>
</div>
</header>
}
}
+63 -4
View File
@@ -3,18 +3,77 @@ component Sidebar {
@event toggle = function
@event open = function
@event close = function
@event select = function
size = "default"
color = "primary"
label = "Sidebar"
items = []
active = ""
orientation = "horizontal"
mobileLabel = "Open navigation"
class = ""
}
state mobileOpen = false
functions {
function toggleSidebar() {
mobileOpen = !mobileOpen
$emit("toggle", { open: mobileOpen })
$emit(mobileOpen ? "open" : "close", { source: "mobile" })
}
function closeSidebar() {
mobileOpen = false
$emit("close", { source: "mobile" })
}
function selectItem(item, level) {
mobileOpen = false
$emit("select", { item: item, value: item.value || "", level: level })
}
}
view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--sidebar wire-next--{orientation} {class}" aria-label="{label}">
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
<slot />
</nav>
<div class="wire-sidebar-shell wire-next--color-{color} wire-next--size-{size} {mobileOpen ? 'is-open' : ''} {class}">
<button type="button" class="wire-sidebar-toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleSidebar()">
<span class="icon-[lucide--panel-left-open]" aria-hidden="true"></span>
<span>{label}</span>
</button>
<button type="button" class="wire-sidebar-backdrop" aria-label="Close navigation" @click="closeSidebar()"></button>
<nav class="wire-next wire-next--sidebar wire-next--{orientation} wire-sidebar-panel" aria-label="{label}">
<div class="wire-sidebar-panel__head">
<strong>{label}</strong>
<button type="button" aria-label="Close navigation" @click="closeSidebar()"><span class="icon-[lucide--x]" aria-hidden="true"></span></button>
</div>
<div class="wire-sidebar-items">
{#each items as item}
{#if item.children && item.children.length}
<details class="wire-sidebar-group" name="wire-sidebar-group">
<summary>
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
<span class="icon-[lucide--chevron-down]" aria-hidden="true"></span>
</summary>
<div class="wire-sidebar-group__children">
{#each item.children as child}
<a href="{child.href || '#'}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span>{child.label}</span>
</a>
{/each}
</div>
</details>
{:else}
<a href="{item.href || '#'}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
{/if}
{/each}
</div>
<slot />
</nav>
</div>
}
}
+123 -12
View File
@@ -38,25 +38,136 @@ component Checkbox {
function preventReadonly(sourceEvent) { if (readonly) sourceEvent.preventDefault() }
}
view {
<div {...attrs} class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--choice-field wire-next--checkbox-field {class}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}" data-orientation="{orientation}" data-card="{card}" data-list="{list}" data-right-aligned="{rightAligned}">
<div class="wire-next__field-heading">{#if options.length || cornerHint}<span class="{hiddenLabel ? 'wire-next__sr-only' : ''}">{label}</span>{#if cornerHint}<span class="wire-next__field-hint">{cornerHint}</span>{/if}{/if}</div>
<div
{...attrs}
class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--choice-field wire-next--checkbox-field {class}"
data-inline="{inline}"
data-invalid="{error ? 'true' : 'false'}"
data-orientation="{orientation}"
data-card="{card}"
data-list="{list}"
data-right-aligned="{rightAligned}"
>
<div
class="wire-next__field-heading"
>
{#if options.length || cornerHint}
<span
class="{hiddenLabel ? 'wire-next__sr-only' : ''}"
>
{label}
</span>
{#if cornerHint}
<span
class="wire-next__field-hint"
>
{cornerHint}
</span>
{/if}
{/if}
</div>
{#if options.length}
<div class="wire-next__checkbox-group" role="group" aria-label="{hiddenLabel ? label : ''}" aria-invalid="{error ? 'true' : 'false'}">
<div
class="wire-next__checkbox-group"
role="group"
aria-label="{hiddenLabel ? label : ''}"
aria-invalid="{error ? 'true' : 'false'}"
>
{#each options as option}
<label class="wire-next__choice wire-next--checkbox" for="{id || name}-{option.value}" data-variant="{variant}" data-disabled="{disabled || option.disabled}">
<input id="{id || name}-{option.value}" type="checkbox" name="{name}" value="{option.value}" checked="{values.includes(option.value) || option.checked}" disabled="{disabled || option.disabled}" required="{required}" readonly="{readonly}" @click="preventReadonly(event)" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="emitField('invalid', event)" />
<span class="wire-next__choice-copy"><strong>{option.label}</strong>{#if option.description}<small>{option.description}</small>{/if}</span>
<label
class="wire-next__choice wire-next--checkbox"
for="{id || name}-{option.value}"
data-variant="{variant}"
data-disabled="{disabled || option.disabled}"
>
<input
id="{id || name}-{option.value}"
type="checkbox"
name="{name}"
value="{option.value}"
checked="{values.includes(option.value) || option.checked}"
disabled="{disabled || option.disabled}"
required="{required}"
readonly="{readonly}"
@click="preventReadonly(event)"
@input="emitField('input', event)"
@change="emitField('change', event)"
@focus="emitField('focus', event)"
@blur="emitField('blur', event)"
@invalid="emitField('invalid', event)"
/>
<span
class="wire-next__choice-copy"
>
<strong>{option.label}</strong>
{#if option.description}
<small>{option.description}</small>
{/if}
</span>
</label>
{/each}
</div>
{:else}
<label class="wire-next__choice wire-next--checkbox" for="{id || name}" data-variant="{variant}" data-icon-position="{iconPosition}">
<input id="{id || name}" type="checkbox" name="{name}" value="{value}" checked="{checked}" disabled="{disabled}" required="{required}" readonly="{readonly}" aria-checked="{indeterminate ? 'mixed' : (checked ? 'true' : 'false')}" aria-invalid="{error ? 'true' : 'false'}" @click="preventReadonly(event)" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="emitField('invalid', event)" />
{#if icon}<span class="{icon}" aria-hidden="true"></span>{/if}
<span class="{hiddenLabel || cornerHint ? 'wire-next__sr-only' : ''}">{label}</span>
</label>
<div
class="wire-next__checkbox-content"
>
<label
class="wire-next__choice wire-next--checkbox"
for="{id || name}"
data-variant="{variant}"
data-icon-position="{iconPosition}"
>
<input
id="{id || name}"
type="checkbox"
name="{name}"
value="{value}"
checked="{checked}"
disabled="{disabled}"
required="{required}"
readonly="{readonly}"
aria-checked="{indeterminate ? 'mixed' : (checked ? 'true' : 'false')}"
aria-invalid="{error ? 'true' : 'false'}"
@click="preventReadonly(event)"
@input="emitField('input', event)"
@change="emitField('change', event)"
@focus="emitField('focus', event)"
@blur="emitField('blur', event)"
@invalid="emitField('invalid', event)"
/>
{#if icon}
<span
class="{icon}"
aria-hidden="true"
>
</span>
{/if}
<span
class="{hiddenLabel || cornerHint ? 'wire-next__sr-only' : ''}"
>
{label}
</span>
<div
class="wire-next__checkbox-slot"
>
<slot></slot>
</div>
</label>
</div>
{/if}
{#if helperText}<small class="wire-next__field-help">{helperText}</small>{/if}<small class="wire-next__field-error" data-error="{name}">{error}</small>
{#if helperText}
<small
class="wire-next__field-help"
>
{helperText}
</small>
{/if}
<small
class="wire-next__field-error"
data-error="{name}"
>
{error}
</small>
</div>
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -26,7 +26,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
"@wrnexus/core": "^0.5.1"
"@wrnexus/core": "^0.5.10"
},
"files": [
"dist",
+1552 -14
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.5.1"
"@wrnexus/core": "^0.5.10"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
+33 -32
View File
@@ -83,11 +83,11 @@
},
"packages/ai": {
"name": "@wrnexus/ai",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/auth": {
"name": "@wrnexus/auth",
"version": "0.5.0-alpha.1",
"version": "0.5.10",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
@@ -98,6 +98,7 @@
"@wrnexus/oauth": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/queue": "workspace:*",
"@wrnexus/ui": "workspace:*",
"@wrnexus/validation": "workspace:*",
},
"devDependencies": {
@@ -108,11 +109,11 @@
},
"packages/authz": {
"name": "@wrnexus/authz",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/captcha": {
"name": "@wrnexus/captcha",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
@@ -126,7 +127,7 @@
},
"packages/cli": {
"name": "@wrnexus/cli",
"version": "0.4.0",
"version": "0.5.10",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -147,29 +148,29 @@
},
"packages/compiler": {
"name": "@wrnexus/compiler",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/syntax": "workspace:*",
},
},
"packages/core": {
"name": "@wrnexus/core",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/csr": {
"name": "@wrnexus/csr",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/db": {
"name": "@wrnexus/db",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
@@ -189,7 +190,7 @@
},
"packages/dev-toolbar": {
"name": "@wrnexus/dev-toolbar",
"version": "0.4.0",
"version": "0.5.10",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2",
@@ -197,63 +198,63 @@
},
"packages/encryption": {
"name": "@wrnexus/encryption",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/helpers": {
"name": "@wrnexus/helpers",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/i18n": {
"name": "@wrnexus/i18n",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/jwt": {
"name": "@wrnexus/jwt",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/mobile": {
"name": "@wrnexus/mobile",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/native": "workspace:*",
},
},
"packages/native": {
"name": "@wrnexus/native",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/oauth": {
"name": "@wrnexus/oauth",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/plugin": {
"name": "@wrnexus/plugin",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/syntax": "workspace:*",
},
},
"packages/pubsub": {
"name": "@wrnexus/pubsub",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/queue": {
"name": "@wrnexus/queue",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/reactive": {
"name": "@wrnexus/reactive",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/router": {
"name": "@wrnexus/router",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
@@ -261,14 +262,14 @@
},
"packages/ssr": {
"name": "@wrnexus/ssr",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/styles": {
"name": "@wrnexus/styles",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
@@ -277,37 +278,37 @@
},
"packages/syntax": {
"name": "@wrnexus/syntax",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/test": {
"name": "@wrnexus/test",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/tracking": {
"name": "@wrnexus/tracking",
"version": "0.4.0",
"version": "0.5.10",
},
"packages/ui": {
"name": "@wrnexus/ui",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/uploader": {
"name": "@wrnexus/uploader",
"version": "0.4.0",
"version": "0.5.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/validation": {
"name": "@wrnexus/validation",
"version": "0.4.0",
"version": "0.5.10",
},
"services/managed-captcha": {
"name": "@wrnexus/managed-captcha-service",
"version": "0.4.0",
"version": "0.5.0",
"dependencies": {
"@wrnexus/captcha": "workspace:*",
},
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 0.5.1
- Updated compiler-backed diagnostics for native array and object state values.
- Added support for unquoted component prop expressions such as `items={items}`,
inline arrays, and inline objects.
- Added readable, idempotent formatting for native JSON state values and direct
structured component props.
- Added extension release validation to prevent the bundled compiler from
drifting behind the framework parser.
## 0.3.0
- Added language support for `runtime`, `hydrate`, `computed`, `effect`, `security`,
+1 -1
View File
@@ -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.5.0",
"version": "0.5.1",
"publisher": "wrnexus",
"private": true,
"license": "SEE LICENSE IN LICENSE",
+251 -23
View File
@@ -3,6 +3,142 @@
const __nodeRequire = require;
const __path = __nodeRequire("node:path");
const __modules = {
"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DependencyGraph = void 0;
exports.compilationKey = compilationKey;
exports.createCompilationCache = createCompilationCache;
const node_crypto_1 = require("node:crypto");
const syntax_1 = require("@wrnexus/syntax");
const codegen_ts_1 = require("./codegen.js");
function compileSource(source, filePath) {
const richDiagnostics = (0, syntax_1.diagnose)(source, { file: filePath, accessibility: true });
const errors = richDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
if (errors.length > 0) {
throw new syntax_1.ParseError(errors.map((diagnostic) => diagnostic.message).join("\n"), errors[0].code);
}
const ast = (0, syntax_1.parse)(source);
return {
code: `// compiled from .wrn\n${(0, codegen_ts_1.generate)(ast)}`,
ast,
diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`),
richDiagnostics,
};
}
function compilationKey(source, file = "<inline .wrn>", salt = "") {
return (0, node_crypto_1.createHash)("sha256")
.update(file)
.update("\0")
.update(salt)
.update("\0")
.update(source)
.digest("hex");
}
function createCompilationCache(options = {}) {
const maxEntries = options.maxEntries ?? 500;
if (!Number.isInteger(maxEntries) || maxEntries < 1)
throw new RangeError("maxEntries must be positive");
const now = options.now ?? Date.now;
const entries = new Map();
let hits = 0;
let misses = 0;
function touch(key, value) {
entries.delete(key);
entries.set(key, value);
while (entries.size > maxEntries)
entries.delete(entries.keys().next().value);
}
return {
compile(source, file = "<inline .wrn>", salt = "") {
const key = compilationKey(source, file, salt);
const existing = entries.get(key);
if (existing) {
hits++;
touch(key, existing);
return existing;
}
misses++;
const result = compileSource(source, file);
const entry = {
...result,
key,
file,
sourceHash: (0, node_crypto_1.createHash)("sha256").update(source).digest("hex"),
createdAt: now(),
};
touch(key, entry);
return entry;
},
get(key) {
const entry = entries.get(key);
if (entry)
touch(key, entry);
return entry;
},
invalidate(file) {
let removed = 0;
for (const [key, entry] of entries) {
if (!file || entry.file === file) {
entries.delete(key);
removed++;
}
}
return removed;
},
clear() {
entries.clear();
},
size: () => entries.size,
stats: () => ({ hits, misses, entries: entries.size }),
};
}
class DependencyGraph {
#dependencies = new Map();
#dependents = new Map();
set(file, dependencies) {
this.remove(file);
const values = new Set(dependencies);
this.#dependencies.set(file, values);
for (const dependency of values) {
const set = this.#dependents.get(dependency) ?? new Set();
set.add(file);
this.#dependents.set(dependency, set);
}
}
remove(file) {
for (const dependency of this.#dependencies.get(file) ?? []) {
const set = this.#dependents.get(dependency);
set?.delete(file);
if (set?.size === 0)
this.#dependents.delete(dependency);
}
this.#dependencies.delete(file);
}
dependencies(file) {
return [...(this.#dependencies.get(file) ?? [])].sort();
}
dependents(file) {
return [...(this.#dependents.get(file) ?? [])].sort();
}
affected(file) {
const found = new Set();
const queue = [file];
while (queue.length) {
const current = queue.shift();
for (const dependent of this.#dependents.get(current) ?? []) {
if (found.has(dependent))
continue;
found.add(dependent);
queue.push(dependent);
}
}
return [...found].sort();
}
}
exports.DependencyGraph = DependencyGraph;
},
"packages/compiler/src/codegen.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
/**
@@ -937,6 +1073,9 @@ function exprRefsState(expr, stateNames) {
}
return false;
}
function exprRefsComponentReactiveValue(expr, ctx) {
return exprRefsState(expr, ctx.stateNames) || exprRefsState(expr, ctx.functionNames);
}
function viewHasEvents(nodes) {
return nodes.some((node) => {
if (node.type === "text")
@@ -999,7 +1138,7 @@ function compileText(raw, ctx) {
// list renderer fills it per item; it has no server-side value.
out += escLit(`{${expr}}`);
}
else if (exprRefsState(expr, ctx.stateNames)) {
else if (exprRefsComponentReactiveValue(expr, ctx)) {
// State interpolation: bake the initial value AND keep it reactive via a
// data-text span, so no-JS clients see the real value and hydration
// updates it in place. `count` → `<span data-text="count">0</span>`.
@@ -1156,6 +1295,7 @@ function renderComponentNode(node, ctx) {
});
}
}
const isExplicitComponentMount = node.attrs.some((attribute) => attribute.name === "data-component");
const attrs = node.attrs
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
.map((a) => {
@@ -1172,7 +1312,7 @@ function renderComponentNode(node, ctx) {
if (isHtmlBooleanAttribute(a.name)) {
const expression = wholeAttributeExpression(a.value);
if (expression) {
const referencesState = exprRefsState(a.value, ctx.stateNames);
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
const referencesLoopVariable = elementContext.loopVars
? exprRefsState(a.value, elementContext.loopVars)
: false;
@@ -1189,8 +1329,12 @@ function renderComponentNode(node, ctx) {
if (a.value === "true" || a.value === "")
return ` ${a.name}`;
}
const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`;
const referencesState = exprRefsState(a.value, ctx.stateNames);
const wholeExpression = wholeAttributeExpression(a.value);
const compiledValue = isExplicitComponentMount && wholeExpression
? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}`
: compileAttrValue(a.value, elementContext);
const rendered = ` ${a.name}="${compiledValue}"`;
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
const referencesLoopVariable = elementContext.loopVars
? exprRefsState(a.value, elementContext.loopVars)
: false;
@@ -1219,7 +1363,7 @@ function renderComponentNode(node, ctx) {
})
.join("");
const staticClassValue = staticClasses.join(" ");
const classReferencesState = exprRefsState(staticClassValue, ctx.stateNames);
const classReferencesState = exprRefsComponentReactiveValue(staticClassValue, ctx);
const classReferencesLoopVariable = elementContext.loopVars
? exprRefsState(staticClassValue, elementContext.loopVars)
: false;
@@ -1243,9 +1387,7 @@ function renderComponentNode(node, ctx) {
const loopLocalsAttribute = serverLoopLocalsAttribute(ctx);
const allAttrs = `${loopLocalsAttribute}` +
`${ctx.forwardRestAttrs ? "${__wireSpreadAttrs(__attrs)}" : ""}` +
`${ctx.forwardRestAttrs && ctx.eventNames?.length
? ` data-wrn-events="${attrEscape(ctx.eventNames.join(","))}"`
: ""}` +
`${ctx.eventNames?.length ? ` data-wrn-events="${attrEscape(ctx.eventNames.join(","))}"` : ""}` +
`${classAttribute}` +
`${classReactiveBinding}` +
`${classBindings}` +
@@ -1261,9 +1403,6 @@ function generateComponent(ast) {
if (ast.imports.length > 0)
out.push(ast.imports.join("\n"));
const hasServerEach = viewHasServerEach(ast.view);
if (hasServerEach) {
out.push(`import { Buffer } from "node:buffer";`);
}
const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content")
? [
{
@@ -1300,6 +1439,9 @@ function generateComponent(ast) {
};
const ctx = {
stateNames,
functionNames: new Set(ast.functions.flatMap((body) => {
return Array.from(body.matchAll(/(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g), (match) => match[1]);
})),
resolveExpr,
eventNames: ast.events.map((event) => event.name),
};
@@ -1330,6 +1472,9 @@ function generateComponent(ast) {
ast.computed.length > 0 ||
viewHasEvents(ast.view) ||
behavior !== null);
if (hasServerEach || needsScope) {
out.push(`import { Buffer as __WrnexusBuffer } from "node:buffer";`);
}
const scopeKeys = [
...effectiveProps.map((prop) => prop.name),
...ast.states.map((state) => state.name),
@@ -1354,16 +1499,16 @@ function generateComponent(ast) {
const returnExpr = needsScope
? "`" +
styleTag +
`<div data-scope="\${__scope}"${behaviorAttr}${hydrationAttribute(ast)}>` +
`<div data-scope="\${__scope}" data-wrn-scope="\${__scopePayload}"${behaviorAttr}${hydrationAttribute(ast)}>` +
viewCode +
"</div>`"
: "`" + styleTag + viewCode + "`";
const scopeLine = needsScope && scopeKeys.length > 0
? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys
? ` const __scopeState = { ${scopeKeys
.map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`)
.join(", ")} });\n`
.join(", ")} };\n const __scope = __wrnexusScopeDecl(__scopeState);\n const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64");\n`
: needsScope
? ` const __scope = "";\n`
? ` const __scopeState = {};\n const __scope = "";\n const __scopePayload = __WrnexusBuffer.from("{}", "utf8").toString("base64");\n`
: "";
if (ast.kind === "layout") {
out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`);
@@ -1553,7 +1698,7 @@ function __wireRaw(v: any): string {
}`);
if (hasServerEach) {
out.push(`function __wrnexusEncodeLoopLocals(value: Record<string, any>): string {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
}`);
}
if (needsScope) {
@@ -1661,7 +1806,7 @@ function renderPageComponentAttr(attr, dynamicExpressions) {
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = void 0;
exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = void 0;
exports.compileNativeWireFile = compileNativeWireFile;
exports.compileWireFile = compileWireFile;
exports.compile = compile;
@@ -1725,6 +1870,10 @@ function compile(source, filePath = "<inline .wrn>") {
richDiagnostics,
};
}
var cache_ts_1 = require("./cache.js");
Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } });
Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } });
},
"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) {
@@ -2211,7 +2360,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.LexError = exports.Lexer = void 0;
exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.LexError = exports.Lexer = void 0;
var tokenizer_ts_1 = require("./tokenizer.js");
Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } });
Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } });
@@ -2235,6 +2384,13 @@ Object.defineProperty(exports, "isHydrationStrategy", { enumerable: true, get: f
Object.defineProperty(exports, "isRuntimeTarget", { enumerable: true, get: function () { return diagnostics_ts_1.isRuntimeTarget; } });
Object.defineProperty(exports, "positionAt", { enumerable: true, get: function () { return diagnostics_ts_1.positionAt; } });
__exportStar(require("./spec.js"), exports);
var versioning_ts_1 = require("./versioning.js");
Object.defineProperty(exports, "WRN_SYNTAX_VERSION", { enumerable: true, get: function () { return versioning_ts_1.WRN_SYNTAX_VERSION; } });
Object.defineProperty(exports, "WRN_SYNTAX_FEATURES", { enumerable: true, get: function () { return versioning_ts_1.WRN_SYNTAX_FEATURES; } });
Object.defineProperty(exports, "createSourceRange", { enumerable: true, get: function () { return versioning_ts_1.createSourceRange; } });
Object.defineProperty(exports, "sliceSource", { enumerable: true, get: function () { return versioning_ts_1.sliceSource; } });
Object.defineProperty(exports, "diagnosticSummary", { enumerable: true, get: function () { return versioning_ts_1.diagnosticSummary; } });
Object.defineProperty(exports, "supportsSyntaxFeature", { enumerable: true, get: function () { return versioning_ts_1.supportsSyntaxFeature; } });
},
"packages/syntax/src/parser.ts": function (module, exports, require, __filename, __dirname) {
@@ -2475,7 +2631,11 @@ function parse(source) {
else {
expect("eq");
}
states.push({ name: sName, valueType, expr: lx.readToLineEnd() });
// State values may be multiline structured expressions. Use the same
// balanced initializer reader as props so formatted arrays/objects
// remain one declaration instead of exposing their inner braces as
// component members on the next line.
states.push({ name: sName, valueType, expr: lx.readPropInitializer() });
break;
}
case "computed": {
@@ -2755,14 +2915,29 @@ function parseHtmlView(src, pos) {
while (i < src.length && isWs(src[i]))
i++;
};
/** Read a `{...}` interpolation (brace-balanced), braces included. */
/** Read a `{...}` interpolation (brace-balanced and quote-aware), braces included. */
const readInterpolation = () => {
const start = i;
let depth = 0;
let quote = null;
for (; i < src.length; i++) {
if (src[i] === "{")
const char = src[i];
if (quote) {
if (char === "\\" && i + 1 < src.length) {
i++;
continue;
}
if (char === quote)
quote = null;
continue;
}
if (char === '"' || char === "'" || char === "`") {
quote = char;
continue;
}
if (char === "{")
depth++;
else if (src[i] === "}" && --depth === 0) {
else if (char === "}" && --depth === 0) {
i++;
return src.slice(start, i);
}
@@ -2852,7 +3027,12 @@ function parseHtmlView(src, pos) {
if (src[i] === "=") {
i++;
skipWs();
attrs.push({ name, value: readQuoted(), event: false });
const value = src[i] === '"' || src[i] === "'"
? readQuoted()
: src[i] === "{"
? readInterpolation()
: fail(`Expected a quoted value or {...} expression after '${name}='`);
attrs.push({ name, value, event: false });
}
else {
attrs.push({ name, value: "", event: false, boolean: true });
@@ -3447,6 +3627,54 @@ function eraseFunctionTypes(source) {
});
}
},
"packages/syntax/src/versioning.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = void 0;
exports.createSourceRange = createSourceRange;
exports.sliceSource = sliceSource;
exports.diagnosticSummary = diagnosticSummary;
exports.supportsSyntaxFeature = supportsSyntaxFeature;
/** Current stable syntax contract. Bump only when parsers/codegen need migration. */
exports.WRN_SYNTAX_VERSION = "0.4";
exports.WRN_SYNTAX_FEATURES = Object.freeze({
"typed-declarations": true,
layouts: true,
"server-client-blocks": true,
effects: true,
watch: true,
lifecycle: true,
"embedded-api": true,
realtime: true,
"runtime-markers": true,
});
function createSourceRange(start, end) {
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
throw new RangeError(`Invalid source range ${start}..${end}`);
}
return { start, end };
}
function sliceSource(source, range) {
return source.slice(range.start, range.end);
}
function diagnosticSummary(diagnostics) {
const summary = { errors: 0, warnings: 0, info: 0, codes: {} };
for (const diagnostic of diagnostics) {
if (diagnostic.severity === "error")
summary.errors++;
else if (diagnostic.severity === "warning")
summary.warnings++;
else
summary.info++;
summary.codes[diagnostic.code] = (summary.codes[diagnostic.code] ?? 0) + 1;
}
return summary;
}
function supportsSyntaxFeature(feature) {
return Object.prototype.hasOwnProperty.call(exports.WRN_SYNTAX_FEATURES, feature);
}
}
};
const __aliases = {
+11 -7
View File
@@ -1155,18 +1155,21 @@ function validateRequiredView(document, source, rootKind, rootMatch) {
];
}
function validateLayoutUsage(document, source, rootKind) {
function validateLayoutUsage(document, source, rootKind, rootMatch) {
const diagnostics = [];
if (rootKind !== "page" && /^\s*layout\s*=/m.test(source)) {
const match = /^\s*layout\s*=/m.exec(source);
if (rootKind !== "page") {
const bodyRange = getRootBodyRange(source, rootMatch);
const layoutMember = findRootMembers(source, bodyRange.start, bodyRange.end).find(
(member) => member.name === "layout",
);
if (match) {
if (layoutMember) {
diagnostics.push(
createDiagnostic(
document,
match.index,
match.index + match[0].length,
layoutMember.start,
layoutMember.end,
'`layout = "..."` is only valid inside a page.',
vscode.DiagnosticSeverity.Error,
"wrn-invalid-layout-member",
@@ -1229,7 +1232,7 @@ function validateDocument(document) {
diagnostics.push(...validateRequiredView(document, source, declaration.kind, declaration.match));
diagnostics.push(...validateLayoutUsage(document, source, declaration.kind));
diagnostics.push(...validateLayoutUsage(document, source, declaration.kind, declaration.match));
return diagnostics;
}
@@ -1325,6 +1328,7 @@ module.exports = {
validateDocument,
validateHtmlTags,
validateLifecycleBlocks,
validateLayoutUsage,
validateRootMembers,
validateWatchBlocks,
};
+113 -7
View File
@@ -153,18 +153,95 @@ function findOpeningTagEnd(value) {
function parseAttributes(value) {
const attributes = [];
let index = 0;
const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s"'=<>`]+))?/g;
while (index < value.length) {
while (index < value.length && /\s/.test(value[index])) index += 1;
if (index >= value.length) break;
let match;
const start = index;
while (index < value.length && !/[\s=]/.test(value[index])) index += 1;
while (index < value.length && /\s/.test(value[index])) index += 1;
while ((match = pattern.exec(value)) !== null) {
attributes.push(match[0].trim());
if (value[index] === "=") {
index += 1;
while (index < value.length && /\s/.test(value[index])) index += 1;
const quote = value[index];
if (quote === '"' || quote === "'") {
index += 1;
let escaped = false;
while (index < value.length) {
const character = value[index++];
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) break;
}
} else if (value[index] === "{") {
let depth = 0;
let expressionQuote = null;
let escaped = false;
while (index < value.length) {
const character = value[index++];
if (expressionQuote !== null) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === expressionQuote) expressionQuote = null;
continue;
}
if (character === '"' || character === "'" || character === "`") {
expressionQuote = character;
} else if (character === "{") {
depth += 1;
} else if (character === "}" && --depth === 0) {
break;
}
}
} else {
while (index < value.length && !/\s/.test(value[index])) index += 1;
}
}
const attribute = value.slice(start, index).trim();
if (attribute) attributes.push(attribute);
}
return attributes;
}
function parseStructuredAttribute(attribute) {
const match = /^([^\s=]+)\s*=\s*\{([\s\S]*)\}$/.exec(attribute);
if (!match) return null;
const expression = match[2].trim();
if (!expression.startsWith("[") && !expression.startsWith("{")) return null;
try {
return {
name: match[1],
value: JSON.parse(expression),
};
} catch {
return null;
}
}
function formatAttribute(attribute, indentation, unit) {
const structured = parseStructuredAttribute(attribute);
if (!structured) return [`${indentation}${attribute}`];
const jsonLines = JSON.stringify(structured.value, null, unit).split("\n");
if (jsonLines.length === 1) {
return [`${indentation}${structured.name}={${jsonLines[0]}}`];
}
return [
`${indentation}${structured.name}={${jsonLines[0]}`,
...jsonLines.slice(1, -1).map((line) => `${indentation}${line}`),
`${indentation}${jsonLines.at(-1)}}`,
];
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -257,7 +334,7 @@ function formatOpeningTag(value, unit, depth, printWidth = 100, multilineAttribu
const lines = [
`${baseIndent}<${parsed.tagName}`,
...parsed.attributes.map((attribute) => `${childIndent}${attribute}`),
...parsed.attributes.flatMap((attribute) => formatAttribute(attribute, childIndent, unit)),
];
if (parsed.selfClosing) {
@@ -328,7 +405,7 @@ function countLeadingClosingBraces(value) {
index += 1;
}
if (value[index] !== "}") {
if (value[index] !== "}" && value[index] !== "]") {
break;
}
@@ -399,6 +476,10 @@ function countStructuralBraces(value) {
openings += 1;
} else if (character === "}") {
closings += 1;
} else if (character === "[") {
openings += 1;
} else if (character === "]") {
closings += 1;
}
}
@@ -457,6 +538,27 @@ function expandInlineControlBlocks(lines) {
});
}
function expandStructuredStateDeclarations(lines, unit) {
return lines.flatMap((line) => {
const match = /^(\s*state\s+[A-Za-z_$][\w$]*\s*=\s*)([\\[{][\s\S]*)$/.exec(line);
if (!match) return [line];
try {
const parsed = JSON.parse(match[2].trim());
const jsonLines = JSON.stringify(parsed, null, unit).split("\n");
if (jsonLines.length === 1) return [`${match[1]}${jsonLines[0]}`];
const leading = match[1].match(/^\s*/)?.[0] ?? "";
return [
`${match[1]}${jsonLines[0]}`,
...jsonLines.slice(1).map((jsonLine) => `${leading}${jsonLine}`),
];
} catch {
return [line];
}
});
}
function formatWrn(source, options = {}) {
const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize ?? 4);
@@ -469,7 +571,10 @@ function formatWrn(source, options = {}) {
let controlDepth = 0;
let index = 0;
const inputLines = expandInlineControlBlocks(source.replace(/\r\n/g, "\n").split("\n"));
const sourceLines = source.replace(/\r\n/g, "\n").split("\n");
const inputLines = expandInlineControlBlocks(
expandStructuredStateDeclarations(sourceLines, unit),
);
const output = [];
@@ -587,6 +692,7 @@ function formatWrn(source, options = {}) {
module.exports = {
countStructuralBraces,
formatOpeningTag,
formatAttribute,
formatWrn,
parseAttributes,
parseOpeningTag,
+36
View File
@@ -31,6 +31,7 @@ const {
maskLeadingTrivia,
validateBalancedCharacters,
validateHtmlTags,
validateLayoutUsage,
validateRootMembers,
} = require("../src/diagnostics");
Module._load = originalLoad;
@@ -136,6 +137,41 @@ test("ignores member-like words inside component line and block comments", () =>
);
});
test("allows a component prop named layout but rejects a root layout member", () => {
const component = `component Card {
props {
layout = "vertical"
}
view { <article>{layout}</article> }
}`;
const componentDeclaration = findTopLevelDeclaration({}, component);
assert.deepEqual(
validateLayoutUsage(
mockDocument(component),
component,
componentDeclaration.kind,
componentDeclaration.match,
),
[],
);
const invalid = `component Card {
layout = "dashboard"
view { <article></article> }
}`;
const invalidDeclaration = findTopLevelDeclaration({}, invalid);
const diagnostics = validateLayoutUsage(
mockDocument(invalid),
invalid,
invalidDeclaration.kind,
invalidDeclaration.match,
);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].code, "wrn-invalid-layout-member");
});
test("ignores HTML-like tags inside WRN comments", () => {
const source = `// The <section> below listens for child events.
// <ComboBox> is only documentation in this comment.
+63
View File
@@ -20,6 +20,69 @@ test("preserves nested prop defaults while formatting", () => {
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2 }), formatted);
});
test("formats native JSON state values with readable indentation", () => {
const source = `component Footer {
state footerItems = [{"label":"Accessibility","href":"/accessibility","value":"accessibility"},{"label":"Privacy","href":"/privacy","value":"privacy"}]
view { <footer></footer> }
}
`;
const expected = `component Footer {
state footerItems = [
{
"label": "Accessibility",
"href": "/accessibility",
"value": "accessibility"
},
{
"label": "Privacy",
"href": "/privacy",
"value": "privacy"
}
]
view { <footer></footer> }
}
`;
const options = { insertSpaces: true, tabSize: 2 };
assert.equal(formatWrn(source, options), expected);
assert.equal(formatWrn(expected, options), expected);
});
test("formats direct structured component props and preserves expression props", () => {
const source = `component Demo {
view {
<Footer items={footerItems} options={{"dense":true,"theme":"public"}} links={[{"label":"Privacy","href":"/privacy"},{"label":"Contact","href":"/contact"}]} />
}
}
`;
const expected = `component Demo {
view {
<Footer
items={footerItems}
options={{
"dense": true,
"theme": "public"
}}
links={[
{
"label": "Privacy",
"href": "/privacy"
},
{
"label": "Contact",
"href": "/contact"
}
]}
/>
}
}
`;
const options = { insertSpaces: true, tabSize: 2, multilineAttributes: true };
assert.equal(formatWrn(source, options), expected);
assert.equal(formatWrn(expected, options), expected);
});
test("formats public event declarations as individual props members", () => {
const source = `component Search {\nprops { value = "" @event search = function @event clear = function }\nview { <input/> }\n}\n`;
const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2 });
+19
View File
@@ -102,6 +102,25 @@ try {
compiler.compileWireFile(good);
ok("compiler accepts valid .wrn");
const structuredProps = `page FooterExample {
state footerItems = [
{
"label": "Accessibility",
"href": "/accessibility",
"value": "accessibility"
}
]
view {
<Footer
items={footerItems}
options={{"dense":true}}
links={[{"label":"Privacy","href":"/privacy"}]}
/>
}
}`;
compiler.compileWireFile(structuredProps);
ok("compiler accepts native structured state and unquoted prop expressions");
const badSrc = `page Home {\n view { <h1>Hi</h2> }\n}`;
let threw = false;
try {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wrnexus",
"version": "0.5.1",
"version": "0.5.4",
"private": true,
"type": "module",
"description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.",
@@ -25,7 +25,7 @@
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"check": "bun run typecheck \u0026\u0026 bun run lint \u0026\u0026 bun run test \u0026\u0026 bun run format:check",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check",
"verify:0.3": "node scripts/verify-0.3.mjs",
"verify:0.4": "node scripts/verify-0.4.mjs",
"validate:0.4": "node scripts/validate-0.4.mjs",
@@ -44,7 +44,7 @@
"typescript-eslint": "latest"
},
"engines": {
"bun": "\u003e=1.3.0"
"bun": ">=1.3.0"
},
"overrides": {
"esbuild": "0.28.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
@@ -36,13 +36,9 @@ component AuthenticatorSetup {
novalidate
class='mt-5 space-y-4 {credentialId == "" ? "" : "hidden"}'
>
<div class="space-y-1.5">
<label for="auth-authenticator-label" class="block text-sm font-medium">Authenticator name</label>
<input id="auth-authenticator-label" name="label" value="Authenticator" autocomplete="off" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" />
<p data-error="label" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input id="auth-authenticator-label" name="label" label="Authenticator name" value="Authenticator" autocomplete="off" icon="icon-[lucide--smartphone]" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{setupLabel}</button>
<Button type="submit" label="{setupLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<div data-auth-authenticator-details class='{credentialId == "" ? "hidden" : ""}'>
@@ -53,15 +49,11 @@ component AuthenticatorSetup {
</div>
<form method="post" action='{action}' data-schema='{schema}' data-auth-authenticator-confirm novalidate class="mt-5 space-y-4">
<input type="hidden" name="credentialId" value='{credentialId}' />
<div class="space-y-1.5">
<label for="auth-authenticator-code" class="block text-sm font-medium">Authenticator code</label>
<input id="auth-authenticator-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<PinInput label="Authenticator code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-error="credentialId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
</div>
</section>
+4 -6
View File
@@ -5,6 +5,7 @@ component ForgotPassword {
title = "Recover your account"
description = "Enter your email, phone, or username."
identifierLabel = "Account identifier"
identifierPlaceholder = "Enter your registered email, mobile number, or username"
submitLabel = "Send recovery link"
successMessage = "If an account matches, a recovery link has been sent."
signInHref = "/sign-in"
@@ -17,14 +18,11 @@ component ForgotPassword {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5">
<label for="auth-recovery-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-recovery-identifier" name="identifier" autocomplete="username" aria-describedby="auth-recovery-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
<p id="auth-recovery-identifier-error" data-error="identifier" role="alert" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input id="auth-recovery-identifier" name="identifier" label="{identifierLabel}" autocomplete="username" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
<slot></slot>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{signInHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Back to sign in</a>
</section>
@@ -22,12 +22,12 @@ component InvitationAccept {
<div class="mt-5 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4 text-sm"><p class="m-0 font-semibold">{organization}</p><p class="mb-0 mt-1 text-[var(--wire-color-muted)]">Invited by {inviter}</p></div>
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-5 space-y-4">
<input type="hidden" name="token" value='{token}' />
<div class="space-y-1.5"><label for="auth-invitation-name" class="block text-sm font-medium">Display name</label><input id="auth-invitation-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<div class="space-y-1.5"><label for="auth-invitation-password" class="block text-sm font-medium">Create password</label><input id="auth-invitation-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<Input id="auth-invitation-name" name="displayName" label="Display name" autocomplete="name" placeholder="Enter your display name" icon="icon-[lucide--user]" color="{color}" size="{size}" />
<StrongPassword label="Create password" name="password" autocomplete="new-password" minLength="12" requireLowercase="true" requireUppercase="true" requireNumber="true" requireSpecialCharacter="true" color="{color}" size="{size}" />
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
</section>
}
+2 -2
View File
@@ -18,10 +18,10 @@ component MagicLinkSignIn {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mb-0 mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-magic-link-identifier" class="block text-sm font-medium">{emailLabel}</label><input id="auth-magic-link-identifier" name="identifier" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" /><p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<Input id="auth-magic-link-identifier" name="identifier" type="email" label="{emailLabel}" autocomplete="email" placeholder="Enter your email address" icon="icon-[lucide--mail]" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{signInHref}' class="mt-4 inline-flex text-sm font-medium text-[var(--wire-color-primary)] hover:underline">Return to password sign-in</a>
</section>
+5 -13
View File
@@ -41,15 +41,11 @@ component OtpSignIn {
class='mt-6 space-y-4 {challengeId == "" ? "" : "hidden"}'
>
<input type="hidden" name="method" value='{method}' />
<div class="space-y-1.5">
<label for="auth-otp-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-otp-identifier" name="identifier" type="text" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input id="auth-otp-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username" placeholder="Enter your email address or phone number" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{requestLabel}</button>
<Button type="submit" label="{requestLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<form
@@ -62,15 +58,11 @@ component OtpSignIn {
>
<input type="hidden" name="challengeId" value='{challengeId}' />
<input type="hidden" name="returnTo" value='{returnTo}' />
<div class="space-y-1.5">
<label for="auth-otp-code" class="block text-sm font-medium">One-time code</label>
<input id="auth-otp-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em] outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<PinInput label="One-time code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{verifyLabel}</button>
<button type="button" data-auth-otp-back class="w-full text-sm text-[var(--wire-color-primary)] hover:underline">Use a different account</button>
<Button type="submit" label="{verifyLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
<Button type="button" data-auth-otp-back label="Use a different account" variant="ghost" color="{color}" size="{size}" fullWidth="true" />
</form>
</section>
}
+2 -7
View File
@@ -18,16 +18,11 @@ component ResetPassword {
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-6 space-y-4">
<input type="hidden" name="token" value='{token}' />
<div class="space-y-1.5">
<label for="auth-reset-password" class="block text-sm font-medium">New password</label>
<input id="auth-reset-password" name="password" type="password" autocomplete="new-password" aria-describedby="auth-reset-password-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
<p id="auth-reset-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<StrongPassword label="New password" name="password" autocomplete="new-password" minLength="12" requireLowercase="true" requireUppercase="true" requireNumber="true" requireSpecialCharacter="true" color="{color}" size="{size}" />
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
</section>
}
+15 -10
View File
@@ -8,7 +8,9 @@ component SignIn {
title = "Welcome back"
description = "Sign in to continue to your account."
identifierLabel = "Email, phone, or username"
identifierPlaceholder = "Enter your email, mobile number, or username"
passwordLabel = "Password"
passwordPlaceholder = "Enter your password"
submitLabel = "Sign in"
forgotHref = "/recover"
signUpHref = "/sign-up"
@@ -29,22 +31,25 @@ component SignIn {
<input type="hidden" name="returnTo" value='{returnTo}' />
<input type="hidden" name="deviceFingerprint" value="" />
<input type="hidden" name="deviceName" value="" />
<Input id="auth-sign-in-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username webauthn" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
<div class="space-y-1.5">
<label for="auth-sign-in-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-sign-in-identifier" name="identifier" type="text" autocomplete="username webauthn" aria-describedby="auth-sign-in-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
<p id="auth-sign-in-identifier-error" data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-3 text-sm font-medium"><label for="auth-sign-in-password">{passwordLabel}</label><a href='{forgotHref}' class="text-xs text-[var(--wire-color-primary)] hover:underline">Forgot password?</a></div>
<input id="auth-sign-in-password" name="password" type="password" autocomplete="current-password" aria-describedby="auth-sign-in-password-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
<p id="auth-sign-in-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<div class="flex justify-end"><a href='{forgotHref}' class="text-xs text-[var(--wire-color-primary)] hover:underline">Forgot password?</a></div>
<TogglePassword
label="{passwordLabel}"
name="password"
placeholder="{passwordPlaceholder}"
autocomplete="current-password"
pattern=""
color="{color}"
size="{size}"
/>
</div>
{#if showRemember}
<label class="flex items-center gap-2 text-sm text-[var(--wire-color-muted)]"><input name="rememberDevice" type="checkbox" class="size-4 rounded border-[var(--wire-color-border)]" />Trust this device</label>
<Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" />
{/if}
<slot></slot>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="inline-flex h-11 w-full items-center justify-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
{#if showPasskey}
<div class="my-4 flex items-center gap-3 text-xs text-[var(--wire-color-muted)]"><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span><span>or</span><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span></div>
+147 -42
View File
@@ -7,6 +7,13 @@ component SignUp {
description = "Use a strong password and verify your contact details."
submitLabel = "Create account"
signInHref = "/sign-in"
termsHref = "/legal/terms"
privacyHref = "/legal/privacy-policy"
namePlaceholder = "Enter your full name"
emailPlaceholder = "Enter your email address"
phonePlaceholder = "Enter your mobile number"
usernamePlaceholder = "Choose a username"
passwordPlaceholder = "Create a strong password"
showPhone = true
showUsername = true
requireConsent = true
@@ -15,10 +22,24 @@ component SignUp {
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<header class="mb-6 space-y-1.5">
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p>
<section
{...attrs}
data-wrnexus-runtime="auth"
class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'
>
<header
class="mb-6 space-y-1.5"
>
<h1
class="m-0 text-2xl font-semibold"
>
{title}
</h1>
<p
class="m-0 text-sm text-[var(--wire-color-muted)]"
>
{description}
</p>
</header>
<form
@@ -29,62 +50,146 @@ component SignUp {
novalidate
class="grid gap-4 sm:grid-cols-2"
>
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-name" class="text-sm font-medium">Full name</label>
<input id="auth-sign-up-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-name"
name="displayName"
label="Full name"
autocomplete="name"
placeholder="{namePlaceholder}"
icon="icon-[lucide--user]"
color="{color}"
size="{size}"
class="sm:col-span-2"
/>
<div class="space-y-1.5">
<label for="auth-sign-up-email" class="text-sm font-medium">Email</label>
<input id="auth-sign-up-email" name="email" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="email" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-email"
name="email"
type="email"
label="Email"
autocomplete="email"
placeholder="{emailPlaceholder}"
icon="icon-[lucide--mail]"
color="{color}"
size="{size}"
/>
{#if showPhone}
<div class="space-y-1.5">
<label for="auth-sign-up-phone" class="text-sm font-medium">Phone</label>
<input id="auth-sign-up-phone" name="phone" type="tel" autocomplete="tel" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="phone" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-phone"
name="phone"
type="tel"
label="Phone"
autocomplete="tel"
placeholder="{phonePlaceholder}"
icon="icon-[lucide--phone]"
color="{color}"
size="{size}"
/>
{/if}
{#if showUsername}
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-username" class="text-sm font-medium">Username</label>
<input id="auth-sign-up-username" name="username" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="username" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-username"
name="username"
label="Username"
autocomplete="username"
placeholder="{usernamePlaceholder}"
icon="icon-[lucide--circle-user-round]"
color="{color}"
size="{size}"
class="sm:col-span-2"
/>
{/if}
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-password" class="text-sm font-medium">Password</label>
<input id="auth-sign-up-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
<p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<div
class="space-y-1.5 sm:col-span-2"
>
<StrongPassword
label="Password"
name="password"
placeholder="{passwordPlaceholder}"
autocomplete="new-password"
minLength="12"
requireLowercase="true"
requireUppercase="true"
requireNumber="true"
requireSpecialCharacter="true"
presentation="inline"
color="{color}"
size="{size}"
/>
</div>
{#if requireConsent}
<div class="space-y-1 sm:col-span-2">
<label class="flex items-start gap-2 text-sm text-[var(--wire-color-muted)]">
<input name="consent" type="checkbox" class="mt-0.5 size-4" />
<span>I agree to the terms and privacy policy.</span>
</label>
<p data-error="consent" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Checkbox
id="auth-sign-up-consent"
name="consent"
label="I agree to the"
color="{color}"
size="{size}"
class="sm:col-span-2"
>
<span
class="flex items-center"
>
<a
href="{termsHref}"
>
terms
</a>
and
<a
href="{privacyHref}"
>
privacy policy
</a>.
</span>
</Checkbox>
{:else}
<input type="hidden" name="consent" value="true" />
<input
type="hidden"
name="consent"
value="true"
/>
{/if}
<div class="sm:col-span-2"><slot></slot></div>
<div
class="sm:col-span-2"
>
<slot></slot>
</div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)] sm:col-span-2"></p>
<p data-success="Account created successfully." role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)] sm:col-span-2"></p>
<p
data-error="_form"
role="alert"
class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)] sm:col-span-2"
>
</p>
<p
data-success="Account created successfully."
role="status"
hidden
class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)] sm:col-span-2"
>
</p>
<button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white sm:col-span-2 disabled:cursor-not-allowed disabled:opacity-60">{submitLabel}</button>
<Button
type="submit"
label="{submitLabel}"
variant="solid"
color="{color}"
size="{size}"
fullWidth="true"
class="sm:col-span-2"
/>
</form>
<p class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]">Already have an account? <a href='{signInHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Sign in</a></p>
<p
class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]"
>
Already have an account? <a href='{signInHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Sign in</a>
</p>
</section>
}
}
@@ -15,6 +15,12 @@ component TwoFactorChallenge {
size = "md"
class = ""
}
state methodOptions = [
{"label":"Authenticator app","value":"totp"},
{"label":"Recovery code","value":"recovery-code"},
{"label":"Email code","value":"email-otp"},
{"label":"SMS code","value":"sms-otp"}
]
view {
<section
{...attrs}
@@ -31,26 +37,13 @@ component TwoFactorChallenge {
<input type="hidden" name="mfaToken" value='{mfaToken}' />
<input type="hidden" name="challengeId" value='{challengeId}' />
<input type="hidden" name="returnTo" value='{returnTo}' />
<div class="space-y-1.5">
<label for="auth-mfa-method" class="block text-sm font-medium">Verification method</label>
<select id="auth-mfa-method" name="method" data-auth-mfa-method class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3">
<option value="totp">Authenticator app</option>
<option value="recovery-code">Recovery code</option>
<option value="email-otp">Email code</option>
<option value="sms-otp">SMS code</option>
</select>
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<div class="space-y-1.5">
<label for="auth-mfa-code" class="block text-sm font-medium">Verification code</label>
<input id="auth-mfa-code" name="code" inputmode="numeric" autocomplete="one-time-code" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Select id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" color="{color}" size="{size}" />
<PinInput label="Verification code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wire-color-primary)]"></p>
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Account recovery options</a>
</section>
+3 -3
View File
@@ -20,17 +20,17 @@ component VerifyEmail {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-email-token" class="block text-sm font-medium">Verification token</label><input id="auth-email-token" name="token" value='{token}' autocomplete="one-time-code" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<Input id="auth-email-token" name="token" label="Verification token" value="{token}" autocomplete="one-time-code" placeholder="Enter your verification token" icon="icon-[lucide--badge-check]" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
<input type="hidden" name="type" value='{kind}' />
<input type="hidden" name="identifier" value='{identifier}' />
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
<Button type="submit" label="Send a new code" variant="ghost" color="{color}" size="sm" />
</form>
</section>
}
+3 -3
View File
@@ -20,17 +20,17 @@ component VerifyPhone {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-phone-token" class="block text-sm font-medium">Verification token</label><input id="auth-phone-token" name="token" value='{token}' autocomplete="one-time-code" inputmode="numeric" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<PinInput label="Verification token" name="token" value="{token}" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
<input type="hidden" name="type" value='{kind}' />
<input type="hidden" name="identifier" value='{identifier}' />
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
<Button type="submit" label="Send a new code" variant="ghost" color="{color}" size="sm" />
</form>
</section>
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.5.1",
"version": "0.5.10",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
@@ -36,7 +36,7 @@
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck \u0026\u0026 bun run test"
"check": "bun run typecheck && bun run test"
},
"dependencies": {
"@wrnexus/authz": "workspace:*",
@@ -47,6 +47,7 @@
"@wrnexus/oauth": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/queue": "workspace:*",
"@wrnexus/ui": "workspace:*",
"@wrnexus/validation": "workspace:*",
"@wrnexus/db": "workspace:*"
},
+21 -1
View File
@@ -9,7 +9,11 @@ import {
getAuthUser,
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "../types.ts";
import type {
AuthSessionVerificationHandler,
AuthSignedInHandler,
AuthSignedOutHandler,
} from "../types.ts";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -53,6 +57,7 @@ export interface AuthHttpOptions {
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
onSessionVerification?: AuthSessionVerificationHandler;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
@@ -83,6 +88,21 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
}
return {
async verifySession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (options.onSessionVerification) {
return options.onSessionVerification(ctx, user);
}
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
if ((ctx.req.headers.get("accept") ?? "").includes("application/json")) {
return json({ ok: true, user });
}
return new Response(null, {
status: 204,
headers: { "cache-control": "no-store" },
});
},
async register(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.register, ctx.req);
if (!validation.ok) return validation.response;
+9 -2
View File
@@ -12,9 +12,16 @@ function wantsJson(ctx: Context): boolean {
return accept.includes("application/json") && !accept.includes("text/html");
}
export function authSession(engine: AuthEngine): Middleware {
export interface AuthSessionOptions {
/** Optional shared SSO cookie containing an AuthEngine session id. */
cookieName?: string;
}
export function authSession(engine: AuthEngine, options: AuthSessionOptions = {}): Middleware {
return async (ctx, next) => {
const sessionId = ctx.session.get<string>(AUTH_SESSION_KEY);
const sessionId =
(options.cookieName ? ctx.cookies.get(options.cookieName) : undefined) ??
ctx.session.get<string>(AUTH_SESSION_KEY);
if (!sessionId) {
ctx.user = null;
ctx.locals.authUser = null;
+27 -5
View File
@@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "./types.ts";
import type {
AuthSessionVerificationHandler,
AuthSignedInHandler,
AuthSignedOutHandler,
} from "./types.ts";
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
import {
clearDefaultAuthEngine,
@@ -52,6 +56,10 @@ export interface AuthConfig {
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
/** Shared SSO cookie whose value is an AuthEngine session id. */
sessionCookieName?: string;
/** Customize the package forward-auth verification response. */
onSessionVerification?: AuthSessionVerificationHandler;
}
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
@@ -88,14 +96,22 @@ interface ResolvedAuthConfig {
passkey?: AuthPasskeyHttpOptions;
onSignedIn?: AuthSignedInHandler;
onSignedOut?: AuthSignedOutHandler;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const moduleRoot = dirname(fileURLToPath(import.meta.url));
const packageRoot = dirname(moduleRoot);
const compiledPackage = moduleRoot === join(packageRoot, "dist");
const clientRuntime = join(packageRoot, "assets", "client", "auth.js");
const migrationsFile = join(packageRoot, "migrations", "001_auth.sql");
const otpPurposeMigrationFile = join(packageRoot, "migrations", "002_auth_otp_purpose.sql");
const apiRoutesDir = join(packageRoot, "src", "routes", "api");
const middlewareFile = join(packageRoot, "src", "routes", "middleware.ts");
const apiRoutesDir = compiledPackage
? join(moduleRoot, "routes", "api")
: join(packageRoot, "src", "routes", "api");
const middlewareFile = compiledPackage
? join(moduleRoot, "routes", "middleware.js")
: join(packageRoot, "src", "routes", "middleware.ts");
const resolvedConfigKey = "@wrnexus/auth:resolved-config";
export function authComponentsDir(): string {
@@ -104,7 +120,7 @@ export function authComponentsDir(): string {
function authApiRouteEntry(path: string): string {
const routeName = path.replace(/^\/api\/auth\/?/, "").replace(/\//g, "-") || "index";
return join(apiRoutesDir, routeName + ".ts");
return join(apiRoutesDir, routeName + (compiledPackage ? ".js" : ".ts"));
}
function resolveConfig(
@@ -142,6 +158,8 @@ function resolveConfig(
passkey: raw.passkey,
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
sessionCookieName: raw.sessionCookieName,
onSessionVerification: raw.onSessionVerification,
};
}
@@ -377,6 +395,10 @@ export function authPlugin(options: AuthPluginOptions = {}) {
onSignedIn: value.onSignedIn,
onSignedOut: value.onSignedOut,
sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification,
});
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
+1
View File
@@ -62,6 +62,7 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
passkey: routeOptions.passkey,
onSignedIn: routeOptions.onSignedIn,
onSignedOut: routeOptions.onSignedOut,
onSessionVerification: routeOptions.onSessionVerification,
});
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifySession", ctx);
}
+6
View File
@@ -23,6 +23,12 @@ export interface AuthRouteDefinition {
/** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [
{
path: "/api/auth/session/verify",
group: "sessions",
handler: "verifySession",
methods: ["GET"],
},
{ path: "/api/auth/register", group: "registration", handler: "register", methods: ["POST"] },
{ path: "/api/auth/login", group: "login", handler: "login", methods: ["POST"] },
{ path: "/api/auth/logout", group: "login", handler: "logout", methods: ["POST"] },
+8 -2
View File
@@ -1,11 +1,17 @@
import type { Middleware } from "@wrnexus/core";
import { authSession } from "../middleware.ts";
import { getDefaultAuthEngine, hasDefaultAuthEngine } from "../runtime.ts";
import {
getDefaultAuthEngine,
getDefaultAuthRouteOptions,
hasDefaultAuthEngine,
} from "../runtime.ts";
/** Package middleware: hydrates auth state when a default engine is configured. */
const middleware: Middleware = async (ctx, next) => {
if (!hasDefaultAuthEngine()) return next();
return authSession(getDefaultAuthEngine())(ctx, next);
return authSession(getDefaultAuthEngine(), {
cookieName: getDefaultAuthRouteOptions().sessionCookieName,
})(ctx, next);
};
export default middleware;
+3
View File
@@ -1,6 +1,7 @@
import type { Context } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions {
@@ -11,6 +12,8 @@ export interface DefaultAuthRouteOptions {
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
}
interface AuthRuntimeState {
+6
View File
@@ -241,6 +241,12 @@ export type AuthSignedInHandler = (ctx: Context, returnTo?: string) => Response
/** HTTP response hook invoked after the auth engine clears a signed-in session. */
export type AuthSignedOutHandler = (ctx: Context) => Response | Promise<Response>;
/** Forward-auth response hook. A null user means the request is unauthenticated. */
export type AuthSessionVerificationHandler = (
ctx: Context,
user: AuthPublicUser | null,
) => Response | Promise<Response>;
export interface AuthSuccessfulSignUpAction {
/**
* Run the normal login policy with the newly registered credentials and
+42
View File
@@ -51,6 +51,48 @@ test("passwordless and passkey components preserve MFA continuation metadata", (
expect(signIn).toContain('name="deviceName"');
});
test("recovery forms expose a form slot for CAPTCHA and policy controls", () => {
const source = readFileSync(join(directory, "ForgotPassword.wrn"), "utf8");
const formStart = source.indexOf("<form");
const formEnd = source.indexOf("</form>");
const slot = source.indexOf("<slot></slot>");
expect(formStart).toBeGreaterThanOrEqual(0);
expect(slot).toBeGreaterThan(formStart);
expect(slot).toBeLessThan(formEnd);
});
test("account forms use the dedicated password components", () => {
const signIn = readFileSync(join(directory, "SignIn.wrn"), "utf8");
const signUp = readFileSync(join(directory, "SignUp.wrn"), "utf8");
expect(signIn).toContain("<TogglePassword");
expect(signIn).not.toContain('id="auth-sign-in-password"');
expect(signUp).toContain("<StrongPassword");
expect(signUp).not.toContain('id="auth-sign-up-password"');
});
test("authentication journeys use themed UI form controls", () => {
const expected = {
"SignIn.wrn": ["<Input", "<TogglePassword", "<Checkbox", "<Button"],
"SignUp.wrn": ["<Input", "<StrongPassword", "<Checkbox", "<Button"],
"ForgotPassword.wrn": ["<Input", "<Button"],
"ResetPassword.wrn": ["<StrongPassword", "<Button"],
"MagicLinkSignIn.wrn": ["<Input", "<Button"],
"InvitationAccept.wrn": ["<Input", "<StrongPassword", "<Button"],
"OtpSignIn.wrn": ["<Input", "<PinInput", "<Button"],
"TwoFactorChallenge.wrn": ["<Select", "<PinInput", "<Button"],
"AuthenticatorSetup.wrn": ["<Input", "<PinInput", "<Button"],
"VerifyEmail.wrn": ["<Input", "<Button"],
"VerifyPhone.wrn": ["<PinInput", "<Button"],
};
for (const [file, controls] of Object.entries(expected)) {
const source = readFileSync(join(directory, file), "utf8");
for (const control of controls) expect(source).toContain(control);
}
});
test("verification components can resend without application-owned schema files", () => {
const email = readFileSync(join(directory, "VerifyEmail.wrn"), "utf8");
const phone = readFileSync(join(directory, "VerifyPhone.wrn"), "utf8");
+20
View File
@@ -178,6 +178,26 @@ test("config.auth registers package routes", async () => {
expect(contributions.migrations).toHaveLength(0);
});
test("config.auth contributes a reusable forward-auth verification route", async () => {
const runner = createPluginRunner(authPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata: new Map<string, unknown>(),
warn() {},
});
await runner.configure({
auth: {
engine: {} as never,
routes: true,
},
});
const contributions = await runner.contributions();
expect(contributions.routes.some((route) => route.path === "/api/auth/session/verify")).toBe(
true,
);
});
test("config.auth can disable route groups", async () => {
const metadata = new Map<string, unknown>();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+27 -5
View File
@@ -415,7 +415,12 @@
}
function toggleItem(state, itemId, button) {
if (state.config.disabled || state.status !== "ready") return;
if (state.config.disabled || (state.status !== "ready" && state.status !== "incorrect")) return;
// An incorrect answer is still the same usable challenge until the server
// expires it or exhausts its attempts. Let the user amend the selection
// immediately and clear the stale error/locked control state.
if (state.status === "incorrect") setStatus(state, "ready", "");
var index = state.selections.indexOf(itemId);
var selected = index >= 0;
@@ -867,10 +872,8 @@
});
}
function onFormSubmit(state, event) {
if (!state.config.required || state.responseToken) return;
event.preventDefault();
event.stopImmediatePropagation();
function passesSubmitGuard(state) {
if (!state.config.required || state.responseToken) return true;
setStatus(state, "incorrect", state.config.requiredMessage);
emit(state, "failure", { code: "missing-input" });
var target =
@@ -878,6 +881,15 @@
state.root.querySelector("[data-captcha-not-robot-button]") ||
state.root.querySelector("[data-captcha-verify]");
if (target && typeof target.focus === "function") target.focus();
return false;
}
function onFormSubmit(state, event) {
// Schema-backed forms run this guard after their ordinary fields validate.
if (state.form && state.form.__wireValidateBound) return;
if (passesSubmitGuard(state)) return;
event.preventDefault();
event.stopImmediatePropagation();
}
function initialize(root) {
@@ -963,6 +975,11 @@
});
if (state.form) {
state.submitGuard = function () {
return passesSubmitGuard(state);
};
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards || [];
state.form.__wireSubmitGuards.push(state.submitGuard);
state.formSubmitListener = function (event) {
onFormSubmit(state, event);
};
@@ -1018,6 +1035,11 @@
if (state.form && state.formSubmitListener) {
state.form.removeEventListener("submit", state.formSubmitListener, true);
}
if (state.form && state.form.__wireSubmitGuards && state.submitGuard) {
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards.filter(function (guard) {
return guard !== state.submitGuard;
});
}
if (state.form && state.formSuccessListener) {
state.form.removeEventListener("wire:success", state.formSuccessListener);
}
+21 -17
View File
@@ -41,6 +41,7 @@ component Captcha {
required = true
resetOnError = true
compact = false
layout = "vertical"
@event ready = function
@event challenge = function
@@ -90,6 +91,7 @@ component Captcha {
data-captcha-required='{required}'
data-captcha-reset-on-error='{resetOnError}'
data-captcha-compact='{compact}'
data-captcha-layout='{layout}'
data-captcha-required-message='{requiredMessage}'
data-captcha-incorrect-message='{incorrectMessage}'
data-captcha-expired-message='{expiredMessage}'
@@ -97,9 +99,9 @@ component Captcha {
data-captcha-status="idle"
data-server-verification-required="true"
aria-busy="false"
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-layout=horizontal]:max-w-2xl data-[captcha-layout=horizontal]:grid data-[captcha-layout=horizontal]:grid-cols-1 sm:data-[captcha-layout=horizontal]:grid-cols-[minmax(0,1fr)_minmax(12rem,0.8fr)] sm:data-[captcha-layout=horizontal]:items-start data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-type=not-robot]/captcha:hidden'>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-layout=horizontal]/captcha:col-span-full group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='min-w-0'>
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
<span
@@ -140,7 +142,7 @@ component Captcha {
Loading challenge…
</div>
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-challenge hidden class='flex min-w-0 flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
@@ -219,20 +221,6 @@ component Captcha {
</button>
</template>
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<input
data-captcha-honeypot
type="text"
@@ -245,6 +233,21 @@ component Captcha {
/>
</div>
<div data-captcha-response-panel class='flex min-w-0 flex-col gap-2 group-data-[captcha-layout=horizontal]/captcha:self-stretch'>
<label data-captcha-answer-wrap hidden class='flex min-w-0 flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-success-message>Verification completed.</span>
@@ -312,6 +315,7 @@ component Captcha {
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
Protected by WRNexus CAPTCHA. Server-side verification is required.
</p>
</div>
</section>
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.5.1",
"version": "0.5.10",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
@@ -38,7 +38,7 @@
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck \u0026\u0026 bun run test"
"check": "bun run typecheck && bun run test"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
+18 -4
View File
@@ -38,6 +38,13 @@ const DEFAULT_TOKEN_TTL_MS = 300_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_MIN_COMPLETION_MS = 800;
const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response";
const RANDOM_CHALLENGE_TYPES: CaptchaChallengeType[] = [
"number",
"alpha",
"alphanumeric",
"calculation",
"image",
];
function failure(
action: string,
@@ -150,13 +157,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
for (const generator of options.generators ?? defaultCaptchaGenerators())
this.generators.set(generator.type, generator);
if (!this.generators.has(this.defaultType))
if (this.defaultType !== "random" && !this.generators.has(this.defaultType))
throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
}
async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
const action = assertAction(options.action);
const requestedType = options.type ?? this.defaultType;
const configuredType = options.type ?? this.defaultType;
const randomInt = (min: number, max: number): number =>
randomInteger(this.randomBytes, min, max);
const randomTypes = RANDOM_CHALLENGE_TYPES.filter((type) => this.generators.has(type));
if (configuredType === "random" && randomTypes.length === 0)
throw new Error("No interactive CAPTCHA generators are registered");
const requestedType =
configuredType === "random"
? randomTypes[randomInt(0, randomTypes.length - 1)]
: configuredType;
const requestedPresentation =
options.presentation ??
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
@@ -169,8 +185,6 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
const now = this.now();
const difficulty = options.difficulty ?? this.defaultDifficulty;
const disturbance = normalizeDisturbance(options.disturbance, difficulty);
const randomInt = (min: number, max: number): number =>
randomInteger(this.randomBytes, min, max);
const imageStyle = resolveCaptchaImageStyle({
imageStyle: options.imageStyle,
allowedStyles: options.allowedStyles,
+1
View File
@@ -4,6 +4,7 @@ export type CaptchaProviderName =
"self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
export type CaptchaChallengeType =
| "random"
| "number"
| "alpha"
| "alphanumeric"
@@ -23,9 +23,14 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
expect(source).toContain("normalizeSize");
expect(source).toContain("verifyNotRobot");
expect(source).toContain("stopImmediatePropagation");
expect(source).toContain("__wireSubmitGuards");
expect(source).toContain("__wireValidateBound");
expect(source).toContain("passesSubmitGuard");
expect(source).toContain('addEventListener("submit"');
expect(source).toContain('addEventListener("wire:error"');
expect(source).toContain("state.config.resetOnError || captchaFailed");
expect(source).toContain('state.status !== "ready" && state.status !== "incorrect"');
expect(source).toContain('state.status === "incorrect") setStatus(state, "ready", "")');
expect(source).toContain("Verified response tokens are single-use");
expect(source).toContain("MutationObserver");
expect(source).toContain("new CustomEvent(name");
+16
View File
@@ -49,6 +49,22 @@ function fixture() {
}
describe("self-hosted CAPTCHA engine", () => {
test("random challenges resolve to an interactive challenge type", async () => {
const engine = createCaptchaEngine({
secret: "captcha-test-secret-with-at-least-thirty-two-characters",
store: new MemoryCaptchaStore(),
defaultType: "random",
minCompletionMs: 0,
});
const supported = new Set(["number", "alpha", "alphanumeric", "calculation", "image"]);
for (let index = 0; index < 12; index += 1) {
const challenge = await engine.create({ action: "random-check" });
expect(supported.has(challenge.type)).toBe(true);
expect(challenge.type).not.toBe("random");
}
});
test("creates, solves, and consumes a challenge and response token", async () => {
const { engine } = fixture();
const challenge = await engine.create({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1
View File
@@ -419,6 +419,7 @@ await createProductionServer(
security: ${JSON.stringify(config.security ?? {})},
observability: ${JSON.stringify(config.observability ?? {})},
tenancy: ${JSON.stringify(config.tenancy ?? {})},
navigation: ${JSON.stringify(config.navigation ?? {})},
},
);
`;
+6 -14
View File
@@ -263,16 +263,14 @@ const config: AppConfig = {
},
},
// Fonts — the framework emits optimized <head> markup (preconnect, subsetted
// Google Fonts with font-display, self-hosted @font-face with preload) and
// auto-extends the CSP for Google Fonts. Uncomment to use a custom font:
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
fonts: {
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
},
//
// fonts: {
// sans: '"Inter", ui-sans-serif, system-ui, sans-serif',
// google: [{ family: "Inter", weights: [400, 500, 600, 700] }],
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
// },
// security: {
// cors: {
@@ -304,13 +302,7 @@ Allow: /
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
body {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
}
`,
"app/layouts/document.wrn": `// Global document layout. The framework renders this once around the selected
+26
View File
@@ -1103,6 +1103,32 @@ const MIGRATIONS: Migration[] = [
// Syntax and compiler support are package-managed; existing applications remain compatible.
},
},
{
version: "0.5.2",
id: "multiline-wrn-json-and-stable-gateway-runtime",
description:
"Supports formatted multiline array and object state initializers and keeps gateway children on the matching framework runtime.",
apply() {
// Syntax, compiler, and gateway fixes are package-managed; no application rewrite is required.
},
},
{
version: "0.5.3",
id: "public-workspace-origins-and-schema-backed-auth-forms",
description:
"Keeps cross-app redirects on configured public origins and enables schema-backed auth forms with active navigation states.",
apply() {
// Gateway, helper, validation, and UI improvements are package-managed.
},
},
{
version: "0.5.10",
id: "latest-release-after-upgrade",
description: "Latest New Packages and release ready packages",
apply() {
// Gateway, helper, validation, and UI improvements are package-managed.
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
+7 -3
View File
@@ -69,9 +69,13 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", ()
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118");
expect(readFileSync(join(root, "app", "styles", "global.css"), "utf8")).toContain(
'@plugin "@iconify/tailwind4";',
);
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
expect(globalCss).toContain('"Plus Jakarta Sans"');
expect(globalCss).toContain("--wire-font-sans");
const appConfig = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
expect(appConfig).toContain('family: "Plus Jakarta Sans"');
expect(appConfig).toContain("weights: [400, 500, 600, 700]");
expect(existsSync(join(root, "public", "llms.txt"))).toBe(true);
expect(existsSync(join(root, "llms.txt"))).toBe(false);
expect(readFileSync(join(root, ".prettierignore"), "utf8")).toContain("CLAUDE.md");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+10 -1
View File
@@ -1437,6 +1437,10 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
}
}
const isExplicitComponentMount = node.attrs.some(
(attribute) => attribute.name === "data-component",
);
const attrs = node.attrs
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
.map((a) => {
@@ -1476,7 +1480,12 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
if (a.value === "true" || a.value === "") return ` ${a.name}`;
}
const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`;
const wholeExpression = wholeAttributeExpression(a.value);
const compiledValue =
isExplicitComponentMount && wholeExpression
? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}`
: compileAttrValue(a.value, elementContext);
const rendered = ` ${a.name}="${compiledValue}"`;
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
+14
View File
@@ -98,6 +98,20 @@ test("component event declarations compile to public root metadata", async () =>
expect(render({})).toContain('data-wrn-events="complete"');
});
test("explicit nested component mounts serialize structured expression props", () => {
const output = compileWireFile(`component Shell {
state brand = { "label": "WRNexus" }
state items = [{ "label": "Home", "href": "/" }]
view {
<div data-component="Navbar" brand={brand} items={items}></div>
}
}`);
expect(output).toContain('data-component="Navbar"');
expect(output).toContain('brand="${__wireProp(brand)}"');
expect(output).toContain('items="${__wireProp(items)}"');
});
test("typed props, required props, state, custom types, and function parameters compile", () => {
const source = `component TypedPicker {
types {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+224
View File
@@ -2011,10 +2011,234 @@ export const REACTIVE_RUNTIME = String.raw`
}
ensureBehaviorObserver();
hydrateNavbarControllers(host);
hydratePreferenceControllers(host);
hydrateSidebarControllers(host);
hydrateDropdownControllers(host);
hydrateSelectControllers(host);
hydratePinInputControllers(host);
}
var navbarOutsideClickBound = false;
var preferenceOutsideClickBound = false;
var dropdownOutsideClickBound = false;
function hydrateSidebarControllers(root) {
var host = root || document;
var sidebars = [];
if (host.nodeType === 1 && host.matches && host.matches(".wire-sidebar-shell")) {
sidebars.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(sidebars, host.querySelectorAll(".wire-sidebar-shell"));
}
sidebars.forEach(function (sidebar) {
var current = window.location.pathname.replace(/\/+$/, "") || "/";
var best = null;
var bestLength = -1;
sidebar.querySelectorAll(".wire-sidebar-items a[href]").forEach(function (link) {
var url;
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
var path = url.pathname.replace(/\/+$/, "") || "/";
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
link.classList.remove("is-active");
if (!matches || path.length <= bestLength) return;
best = link;
bestLength = path.length;
});
sidebar.querySelectorAll(".wire-sidebar-items a[aria-current='page']").forEach(function (link) {
if (link !== best) link.removeAttribute("aria-current");
});
sidebar.querySelectorAll(".wire-sidebar-group.has-active").forEach(function (group) {
group.classList.remove("has-active");
});
if (best) {
best.classList.add("is-active");
best.setAttribute("aria-current", "page");
var group = best.closest(".wire-sidebar-group");
if (group) {
group.setAttribute("open", "");
group.classList.add("has-active");
}
}
});
}
function hydrateDropdownControllers(root) {
var host = root || document;
var dropdowns = [];
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-dropdown]")) {
dropdowns.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(dropdowns, host.querySelectorAll("[data-wrn-dropdown]"));
}
dropdowns.forEach(function (dropdown) {
if (dropdown.dataset.wrnDropdownBound === "true") return;
dropdown.dataset.wrnDropdownBound = "true";
dropdown.addEventListener("toggle", function () {
if (!dropdown.open) return;
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (candidate) {
if (candidate !== dropdown) candidate.removeAttribute("open");
});
});
});
if (!dropdownOutsideClickBound) {
dropdownOutsideClickBound = true;
document.addEventListener("pointerdown", function (event) {
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
if (!dropdown.contains(event.target)) dropdown.removeAttribute("open");
});
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
dropdown.removeAttribute("open");
var summary = dropdown.querySelector(":scope > summary");
if (summary) summary.focus();
});
});
}
}
function hydratePreferenceControllers(root) {
var host = root || document;
var switchers = [];
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-preferences]")) {
switchers.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(
switchers,
host.querySelectorAll("[data-wrn-preferences]"),
);
}
switchers.forEach(function (switcher) {
if (switcher.dataset.wrnPreferencesBound === "true") return;
switcher.dataset.wrnPreferencesBound = "true";
switcher.addEventListener("toggle", function (event) {
var opened = event.target;
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
if (menu !== opened) menu.removeAttribute("open");
});
}, true);
});
if (!preferenceOutsideClickBound) {
preferenceOutsideClickBound = true;
document.addEventListener("pointerdown", function (event) {
document.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
if (switcher.contains(event.target)) return;
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
menu.removeAttribute("open");
});
});
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
document.querySelectorAll("[data-wrn-preferences] .wire-preferences__menu[open]").forEach(function (menu) {
menu.removeAttribute("open");
var summary = menu.querySelector(":scope > summary");
if (summary) summary.focus();
});
});
}
}
function hydrateNavbarControllers(root) {
var host = root || document;
var navbars = [];
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-navbar]")) {
navbars.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(
navbars,
host.querySelectorAll("[data-wrn-navbar]"),
);
}
navbars.forEach(function (navbar) {
var current = window.location.pathname.replace(/\/+$/, "") || "/";
var best = null;
var bestLength = -1;
navbar.querySelectorAll(".wire-navbar__menus a[href]").forEach(function (link) {
var url;
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
if (url.origin !== window.location.origin) return;
var path = url.pathname.replace(/\/+$/, "") || "/";
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
if (!matches || path.length <= bestLength) return;
best = link;
bestLength = path.length;
});
navbar.querySelectorAll(".wire-navbar__menus [aria-current='page']").forEach(function (item) {
item.removeAttribute("aria-current");
});
if (best) {
best.setAttribute("aria-current", "page");
var parentMenu = best.closest(".wire-navbar__dropdown");
var parentSummary = parentMenu && parentMenu.querySelector(":scope > summary");
if (parentSummary) parentSummary.setAttribute("aria-current", "page");
}
if (navbar.dataset.wrnNavbarBound === "true") return;
navbar.dataset.wrnNavbarBound = "true";
var hoverEnabled = navbar.getAttribute("data-open-on-hover") === "true";
var hoverCapable =
typeof window.matchMedia !== "function" ||
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (hoverEnabled && hoverCapable) {
navbar.querySelectorAll(".wire-navbar__dropdown").forEach(function (menu) {
menu.addEventListener("pointerenter", function () {
if (menu.__wrnNavbarCloseTimer) {
clearTimeout(menu.__wrnNavbarCloseTimer);
menu.__wrnNavbarCloseTimer = null;
}
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (candidate) {
if (candidate !== menu) candidate.removeAttribute("open");
});
menu.setAttribute("open", "");
});
menu.addEventListener("pointerleave", function () {
if (menu.__wrnNavbarCloseTimer) clearTimeout(menu.__wrnNavbarCloseTimer);
menu.__wrnNavbarCloseTimer = setTimeout(function () {
menu.removeAttribute("open");
menu.__wrnNavbarCloseTimer = null;
}, 240);
});
});
}
navbar.addEventListener("toggle", function (event) {
var opened = event.target;
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
if (menu !== opened) menu.removeAttribute("open");
});
}, true);
});
if (!navbarOutsideClickBound) {
navbarOutsideClickBound = true;
document.addEventListener("pointerdown", function (event) {
document.querySelectorAll("[data-wrn-navbar]").forEach(function (navbar) {
if (navbar.contains(event.target)) return;
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
menu.removeAttribute("open");
});
});
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
document.querySelectorAll("[data-wrn-navbar] .wire-navbar__dropdown[open]").forEach(function (menu) {
menu.removeAttribute("open");
var summary = menu.querySelector(":scope > summary");
if (summary) summary.focus();
});
});
}
}
var selectOutsideClickBound = false;
function selectScopeApi(root) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+12 -5
View File
@@ -23,14 +23,21 @@ interface ExecMeta {
insertId?: number | bigint;
}
function runnerFor(client: BunSqlClient): TxHandle {
/** Convert the framework's portable positional placeholders for PostgreSQL. */
export function sqlForDialect(sql: string, dialect: Dialect): string {
if (dialect !== "postgres" || !sql.includes("?")) return sql;
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
function runnerFor(client: BunSqlClient, dialect: Dialect): TxHandle {
return {
async query(sql, params = []): Promise<Row[]> {
const rows = (await client.unsafe(sql, params)) as Iterable<Row>;
const rows = (await client.unsafe(sqlForDialect(sql, dialect), params)) as Iterable<Row>;
return Array.from(rows);
},
async exec(sql, params = []) {
const meta = (await client.unsafe(sql, params)) as ExecMeta;
const meta = (await client.unsafe(sqlForDialect(sql, dialect), params)) as ExecMeta;
const id = meta.lastInsertRowid ?? meta.insertId;
return {
changes: Number(meta.affectedRows ?? meta.count ?? 0),
@@ -44,13 +51,13 @@ function runnerFor(client: BunSqlClient): TxHandle {
export function bunSql(url: string, dialect: Dialect): Driver {
const Ctor = (Bun as unknown as { SQL: new (u: string) => BunSqlClient }).SQL;
const client = new Ctor(url);
const runner = runnerFor(client);
const runner = runnerFor(client, dialect);
return {
dialect,
query: runner.query,
exec: runner.exec,
transaction(fn) {
return client.begin((tx) => fn(runnerFor(tx)));
return client.begin((tx) => fn(runnerFor(tx, dialect)));
},
close() {
return client.close();
+22 -6
View File
@@ -12,7 +12,9 @@
import type { Db } from "./driver.ts";
const DEFAULT = "default";
const registry = new Map<string, Db>();
type DbFactory = () => Db;
type RegistryEntry = { db?: Db; factory?: DbFactory };
const registry = new Map<string, RegistryEntry>();
/** Set the default database (called by the runtime at startup). */
export function setDb(db: Db): Db;
@@ -21,7 +23,7 @@ export function setDb(name: string, db: Db): Db;
export function setDb(a: string | Db, b?: Db): Db {
const name = typeof a === "string" ? a : DEFAULT;
const db = typeof a === "string" ? b! : a;
registry.set(name, db);
registry.set(name, { db });
return db;
}
@@ -30,10 +32,18 @@ export function registerDb(name: string, db: Db): Db {
return setDb(name, db);
}
/**
* Register a named database without opening its connection pool. The first
* `getDb(name)` call creates and caches the connection.
*/
export function registerLazyDb(name: string, factory: DbFactory): void {
registry.set(name, { factory });
}
/** The default database, or a named one. Throws if it isn't configured. */
export function getDb(name = DEFAULT): Db {
const db = registry.get(name);
if (!db) {
const entry = registry.get(name);
if (!entry) {
throw new Error(
name === DEFAULT
? "No database configured. Add `db: { driver, url }` to wrnexus.config.ts."
@@ -41,7 +51,11 @@ export function getDb(name = DEFAULT): Db {
`(e.g. databases: { ${name}: { driver, url } }).`,
);
}
return db;
if (!entry.db) {
if (!entry.factory) throw new Error(`Database '${name}' has no connection factory.`);
entry.db = entry.factory();
}
return entry.db;
}
/** Whether the default (or a named) database has been configured. */
@@ -56,6 +70,8 @@ export function databaseNames(): string[] {
/** Close every configured database and clear the registry. */
export async function closeDatabases(): Promise<void> {
for (const db of registry.values()) await db.close();
for (const entry of registry.values()) {
if (entry.db) await entry.db.close();
}
registry.clear();
}
+9 -1
View File
@@ -10,7 +10,15 @@ export { v, table, Column } from "./schema.ts";
export type { Model, Columns, ColumnDef, BaseType } from "./schema.ts";
export { createDb } from "./driver.ts";
export type { Db, Driver, Row, ExecResult, TxHandle } from "./driver.ts";
export { setDb, getDb, hasDb, registerDb, databaseNames, closeDatabases } from "./client.ts";
export {
setDb,
getDb,
hasDb,
registerDb,
registerLazyDb,
databaseNames,
closeDatabases,
} from "./client.ts";
export { createTableSql } from "./sql.ts";
export type { Dialect } from "./sql.ts";
export {
+1
View File
@@ -65,6 +65,7 @@ export async function appliedMigrations(db: Db): Promise<string[]> {
/** Apply an ordered migration list (each in a transaction). Returns applied names. */
export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise<string[]> {
if (migrations.length === 0) return [];
const applied = new Set(await appliedMigrations(db));
const pending = migrations.filter((migration) => !applied.has(migration.name));
const done: string[] = [];
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { sqlForDialect } from "../src/adapters/bunsql.ts";
test("PostgreSQL converts portable placeholders to numbered parameters", () => {
expect(sqlForDialect("SELECT * FROM users WHERE id = ? AND status = ?", "postgres")).toBe(
"SELECT * FROM users WHERE id = $1 AND status = $2",
);
});
test("MySQL and parameter-free PostgreSQL statements remain unchanged", () => {
expect(sqlForDialect("SELECT * FROM users WHERE id = ?", "mysql")).toBe(
"SELECT * FROM users WHERE id = ?",
);
expect(sqlForDialect("CREATE TABLE users (id TEXT)", "postgres")).toBe(
"CREATE TABLE users (id TEXT)",
);
});
+16
View File
@@ -8,12 +8,14 @@ import {
createDb,
createTableSql,
parseMigration,
applyMigrations,
migrate,
status,
rollback,
parseQueries,
generateQueriesFile,
} from "../src/index.ts";
import type { Db } from "../src/index.ts";
import { sqlite } from "../src/adapters/sqlite.ts";
import { bunSql } from "../src/adapters/bunsql.ts";
@@ -95,6 +97,20 @@ test("migration runner: parse, migrate, status, rollback", async () => {
db.close();
});
test("an empty migration set does not touch the database", async () => {
const unreachable = async () => {
throw new Error("database should remain lazy");
};
const db = {
all: unreachable,
one: unreachable,
exec: unreachable,
tx: unreachable,
} as unknown as Db;
expect(await applyMigrations(db, [])).toEqual([]);
});
test("query generator infers params and result types", () => {
const q = parseQueries(
"-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" +
+33
View File
@@ -5,6 +5,7 @@ import {
getDb,
hasDb,
registerDb,
registerLazyDb,
databaseNames,
closeDatabases,
} from "../src/index.ts";
@@ -45,3 +46,35 @@ test("getDb throws a helpful error for an unknown named database", async () => {
expect(() => getDb("nope")).toThrow(/No database named 'nope'/);
await closeDatabases();
});
test("named databases can be registered without opening a connection", async () => {
await closeDatabases();
let calls = 0;
const analytics = createDb(sqlite(":memory:"));
registerLazyDb("analytics", () => {
calls++;
return analytics;
});
expect(hasDb("analytics")).toBe(true);
expect(databaseNames()).toEqual(["analytics"]);
expect(calls).toBe(0);
expect(getDb("analytics")).toBe(analytics);
expect(getDb("analytics")).toBe(analytics);
expect(calls).toBe(1);
await closeDatabases();
});
test("closing the registry does not instantiate unused lazy databases", async () => {
await closeDatabases();
let calls = 0;
registerLazyDb("unused", () => {
calls++;
return createDb(sqlite(":memory:"));
});
await closeDatabases();
expect(calls).toBe(0);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {

Some files were not shown because too many files have changed in this diff Show More