release: WRNexusJS 0.4.0
This commit is contained in:
@@ -7,3 +7,5 @@ dist/
|
||||
*.db-wal
|
||||
.DS_Store
|
||||
bun.lockb
|
||||
|
||||
.publish/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/authz — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/cli — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -28,18 +28,18 @@
|
||||
"wrnexus": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6",
|
||||
"@wrnexus/router": "^0.3.6",
|
||||
"@wrnexus/csr": "^0.3.6",
|
||||
"@wrnexus/compiler": "^0.3.6",
|
||||
"@wrnexus/styles": "^0.3.6",
|
||||
"@wrnexus/dev-server": "^0.3.6",
|
||||
"@wrnexus/ui": "^0.3.6",
|
||||
"@wrnexus/validation": "^0.3.6",
|
||||
"@wrnexus/i18n": "^0.3.6",
|
||||
"@wrnexus/db": "^0.3.6",
|
||||
"@wrnexus/plugin": "^0.3.6",
|
||||
"@wrnexus/syntax": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0",
|
||||
"@wrnexus/router": "^0.4.0",
|
||||
"@wrnexus/csr": "^0.4.0",
|
||||
"@wrnexus/compiler": "^0.4.0",
|
||||
"@wrnexus/styles": "^0.4.0",
|
||||
"@wrnexus/dev-server": "^0.4.0",
|
||||
"@wrnexus/ui": "^0.4.0",
|
||||
"@wrnexus/validation": "^0.4.0",
|
||||
"@wrnexus/i18n": "^0.4.0",
|
||||
"@wrnexus/db": "^0.4.0",
|
||||
"@wrnexus/plugin": "^0.4.0",
|
||||
"@wrnexus/syntax": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/compiler — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "^0.3.6"
|
||||
"@wrnexus/syntax": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/core — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/csr — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/db — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -25,20 +25,20 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6",
|
||||
"@wrnexus/dev-toolbar": "^0.3.6",
|
||||
"@wrnexus/router": "^0.3.6",
|
||||
"@wrnexus/ssr": "^0.3.6",
|
||||
"@wrnexus/csr": "^0.3.6",
|
||||
"@wrnexus/compiler": "^0.3.6",
|
||||
"@wrnexus/styles": "^0.3.6",
|
||||
"@wrnexus/ui": "^0.3.6",
|
||||
"@wrnexus/validation": "^0.3.6",
|
||||
"@wrnexus/i18n": "^0.3.6",
|
||||
"@wrnexus/db": "^0.3.6",
|
||||
"@wrnexus/pubsub": "^0.3.6",
|
||||
"@wrnexus/uploader": "^0.3.6",
|
||||
"@wrnexus/plugin": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0",
|
||||
"@wrnexus/dev-toolbar": "^0.4.0",
|
||||
"@wrnexus/router": "^0.4.0",
|
||||
"@wrnexus/ssr": "^0.4.0",
|
||||
"@wrnexus/csr": "^0.4.0",
|
||||
"@wrnexus/compiler": "^0.4.0",
|
||||
"@wrnexus/styles": "^0.4.0",
|
||||
"@wrnexus/ui": "^0.4.0",
|
||||
"@wrnexus/validation": "^0.4.0",
|
||||
"@wrnexus/i18n": "^0.4.0",
|
||||
"@wrnexus/db": "^0.4.0",
|
||||
"@wrnexus/pubsub": "^0.4.0",
|
||||
"@wrnexus/uploader": "^0.4.0",
|
||||
"@wrnexus/plugin": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/dev-toolbar — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/encryption — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/i18n — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/jwt — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/mobile — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/native": "^0.3.6"
|
||||
"@wrnexus/native": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/native — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/oauth — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/plugin — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -18,10 +18,22 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./manifest": {
|
||||
"types": "./dist/manifest.d.ts",
|
||||
"import": "./dist/manifest.js"
|
||||
},
|
||||
"./discovery": {
|
||||
"types": "./dist/discovery.d.ts",
|
||||
"import": "./dist/discovery.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "^0.3.6"
|
||||
"@wrnexus/syntax": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/queue — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/reactive — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/router — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,8 +21,8 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "^0.3.6",
|
||||
"@wrnexus/core": "^0.3.6"
|
||||
"@wrnexus/compiler": "^0.4.0",
|
||||
"@wrnexus/core": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/ssr — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/styles — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,9 +21,9 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/uploader": "^0.3.6",
|
||||
"@wrnexus/core": "^0.3.6",
|
||||
"@wrnexus/plugin": "^0.3.6"
|
||||
"@wrnexus/uploader": "^0.4.0",
|
||||
"@wrnexus/core": "^0.4.0",
|
||||
"@wrnexus/plugin": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/syntax — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/test — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/tracking — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -36,9 +36,9 @@ Theme-aware, responsive copy markup component.
|
||||
Theme-aware, responsive input number component.
|
||||
|
||||
- Mount: `data-component="InputNumber"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Input Number"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "number"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `class: string = ""`, `id: string = ""`, `name: string = "quantity"`, `value: number = 0`, `min: string = ""`, `max: string = ""`, `step: number = 1`, `precision: string = "auto"`, `label: string = ""`, `description: string = ""`, `helpText: string = ""`, `error: string = ""`, `invalid: boolean = false`, `prefix: string = ""`, `suffix: string = ""`, `placeholder: string = ""`, `autocomplete: string = "off"`, `inputMode: string = "decimal"`, `ariaLabel: string = ""`, `required: boolean = false`, `disabled: boolean = false`, `inputDisabled: boolean = false`, `buttonsDisabled: boolean = false`, `readonly: boolean = false`, `allowInput: boolean = true`, `keyboard: boolean = true`, `wheel: boolean = false`, `clamp: boolean = true`, `fullWidth: boolean = false`, `showButtons: boolean = true`, `showValidationMessage: boolean = true`, `decrementLabel: string = "Decrease value"`, `incrementLabel: string = "Increase value"`, `controlsLabel: string = "Quantity controls"`, `requiredMessage: string = "A value is required."`, `minMessage: string = "Value is below the minimum."`, `maxMessage: string = "Value is above the maximum."`
|
||||
- Slots: None
|
||||
- Events: None
|
||||
- Events: `input`, `change`, `increment`, `decrement`
|
||||
|
||||
### PinInput
|
||||
|
||||
|
||||
+14
-12
@@ -15,7 +15,7 @@ driven by `var(--wire-*)` theme tokens, so components restyle instantly when the
|
||||
theme changes. The tiny JS surface (`src/index.ts`) exists only so the toolchain
|
||||
(CLI build + dev server) can locate the component directory and stylesheet.
|
||||
|
||||
The complete PDF-aligned catalog currently contains **891 components**. The
|
||||
The complete PDF-aligned catalog currently contains **85 components**. The
|
||||
generated `COMPONENTS.md` and `component-reference.json` files document every
|
||||
mount name, prop, inferred type, default/required status, slot, event, category,
|
||||
and source file directly from the packaged `.wrn` source.
|
||||
@@ -35,10 +35,11 @@ In practice you rarely install this directly: `@wrnexus/cli` and
|
||||
|
||||
## Components
|
||||
|
||||
Components live as `.wrn` files under `packages/ui/components/`. The mount name
|
||||
is the **lowercase file basename** (e.g. `button.wrn` → `data-component="button"`).
|
||||
Each accepts a `class` prop (appended to its root element) and most render their
|
||||
body from either a named prop or the default slot.
|
||||
Components live as `.wrn` files under `packages/ui/components/`. The canonical mount
|
||||
name comes from the component declaration (for example, `component Button` mounts as
|
||||
`data-component="Button"`). Component lookup is case-insensitive, so existing lowercase
|
||||
mounts continue to work. Each component accepts a `class` prop (appended to its root
|
||||
element), and most render their body from either a named prop or the default slot.
|
||||
|
||||
### Layout
|
||||
|
||||
@@ -91,16 +92,17 @@ equivalent is exported as `@wrnexus/ui/component-reference.json`.
|
||||
|
||||
## API
|
||||
|
||||
The JS module (`@wrnexus/ui`) exposes four helpers used by the build tooling to
|
||||
The JS module (`@wrnexus/ui`) exposes five helpers used by the build tooling to
|
||||
locate the component assets. There is no component code to import — the components
|
||||
are `.wrn` files rendered server-side.
|
||||
|
||||
| Export | Signature | Returns |
|
||||
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
|
||||
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
|
||||
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
|
||||
| `uiComponentNames` | `() => string[]` | Sorted list of built-in component names (e.g. for `wrnexus eject` listing). |
|
||||
| Export | Signature | Returns |
|
||||
| ------------------ | -------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
|
||||
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
|
||||
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
|
||||
| `uiComponentNames` | `() => string[]` | Sorted list of declared built-in component names. |
|
||||
| `uiComponentPath` | `(name: string) => string` | Absolute source path for a declared component name or case-insensitive alias. |
|
||||
|
||||
### `./ui.css` asset export
|
||||
|
||||
|
||||
@@ -517,7 +517,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Alert.wrn"
|
||||
"source": "components/alert.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Avatar",
|
||||
@@ -570,7 +570,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Avatar.wrn"
|
||||
"source": "components/avatar.wrn"
|
||||
},
|
||||
{
|
||||
"name": "AvatarGroup",
|
||||
@@ -676,7 +676,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Badge.wrn"
|
||||
"source": "components/badge.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Blockquote",
|
||||
@@ -943,7 +943,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Button.wrn"
|
||||
"source": "components/button.wrn"
|
||||
},
|
||||
{
|
||||
"name": "ButtonGroup",
|
||||
@@ -1049,7 +1049,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Card.wrn"
|
||||
"source": "components/card.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Carousel",
|
||||
@@ -1267,7 +1267,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Checkbox.wrn"
|
||||
"source": "components/checkbox.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Clipboard",
|
||||
@@ -1754,7 +1754,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": ["search", "select", "change", "clear", "open", "close", "load", "error"],
|
||||
"source": "components/ComboBox.wrn"
|
||||
"source": "components/Combobox.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Confetti",
|
||||
@@ -1854,7 +1854,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Container.wrn"
|
||||
"source": "components/container.wrn"
|
||||
},
|
||||
{
|
||||
"name": "ContextMenu",
|
||||
@@ -2338,7 +2338,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Divider.wrn"
|
||||
"source": "components/divider.wrn"
|
||||
},
|
||||
{
|
||||
"name": "DragAndDrop",
|
||||
@@ -2751,7 +2751,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Grid.wrn"
|
||||
"source": "components/grid.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Image",
|
||||
@@ -2905,7 +2905,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Input.wrn"
|
||||
"source": "components/input.wrn"
|
||||
},
|
||||
{
|
||||
"name": "InputGroup",
|
||||
@@ -3015,34 +3015,34 @@
|
||||
"default": "\"primary\""
|
||||
},
|
||||
{
|
||||
"name": "label",
|
||||
"name": "variant",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Input Number\""
|
||||
"default": "\"default\""
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
"default": "\"quantity\""
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "string",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "placeholder",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"number\""
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "min",
|
||||
@@ -3058,16 +3058,82 @@
|
||||
},
|
||||
{
|
||||
"name": "step",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
"name": "precision",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"auto\""
|
||||
},
|
||||
{
|
||||
"name": "label",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "disabled",
|
||||
"name": "description",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "helpText",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "invalid",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "prefix",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "suffix",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "placeholder",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "autocomplete",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"off\""
|
||||
},
|
||||
{
|
||||
"name": "inputMode",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"decimal\""
|
||||
},
|
||||
{
|
||||
"name": "ariaLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "required",
|
||||
"type": "boolean",
|
||||
@@ -3075,14 +3141,110 @@
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"name": "disabled",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "inputDisabled",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "buttonsDisabled",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "readonly",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "allowInput",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "keyboard",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "wheel",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "clamp",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "fullWidth",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "showButtons",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "showValidationMessage",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "decrementLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
"default": "\"Decrease value\""
|
||||
},
|
||||
{
|
||||
"name": "incrementLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Increase value\""
|
||||
},
|
||||
{
|
||||
"name": "controlsLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Quantity controls\""
|
||||
},
|
||||
{
|
||||
"name": "requiredMessage",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"A value is required.\""
|
||||
},
|
||||
{
|
||||
"name": "minMessage",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Value is below the minimum.\""
|
||||
},
|
||||
{
|
||||
"name": "maxMessage",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Value is above the maximum.\""
|
||||
}
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"events": ["input", "change", "increment", "decrement"],
|
||||
"source": "components/InputNumber.wrn"
|
||||
},
|
||||
{
|
||||
@@ -4039,7 +4201,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Progress.wrn"
|
||||
"source": "components/progress.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Radio",
|
||||
@@ -4098,7 +4260,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Radio.wrn"
|
||||
"source": "components/radio.wrn"
|
||||
},
|
||||
{
|
||||
"name": "RangeSlider",
|
||||
@@ -4453,7 +4615,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Select.wrn"
|
||||
"source": "components/select.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Sidebar",
|
||||
@@ -4547,7 +4709,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Skeleton.wrn"
|
||||
"source": "components/skeleton.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Spinner",
|
||||
@@ -4588,7 +4750,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Spinner.wrn"
|
||||
"source": "components/spinner.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Stepper",
|
||||
@@ -4926,7 +5088,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Switch.wrn"
|
||||
"source": "components/switch.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Table",
|
||||
@@ -4979,7 +5141,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Table.wrn"
|
||||
"source": "components/table.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Tabs",
|
||||
@@ -5103,7 +5265,7 @@
|
||||
],
|
||||
"slots": [],
|
||||
"events": [],
|
||||
"source": "components/Textarea.wrn"
|
||||
"source": "components/textarea.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Timeline",
|
||||
@@ -5654,7 +5816,7 @@
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": [],
|
||||
"source": "components/Tooltip.wrn"
|
||||
"source": "components/tooltip.wrn"
|
||||
},
|
||||
{
|
||||
"name": "TreeView",
|
||||
|
||||
@@ -1,20 +1,623 @@
|
||||
component InputNumber {
|
||||
props {
|
||||
size = "default"
|
||||
color = "primary"
|
||||
label = "Input Number"
|
||||
name = ""
|
||||
value = ""
|
||||
placeholder = ""
|
||||
type = "number"
|
||||
min = ""
|
||||
max = ""
|
||||
step = ""
|
||||
disabled = false
|
||||
required = false
|
||||
class = ""
|
||||
}
|
||||
view {
|
||||
<label class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--input-number {class}"><span>{label}</span><input {...attrs} type="{type}" name="{name}" value="{value}" placeholder="{placeholder}" min="{min}" max="{max}" step="{step}" disabled="{disabled}" required="{required}" /></label>
|
||||
}
|
||||
props {
|
||||
size = "default"
|
||||
color = "primary"
|
||||
variant = "default"
|
||||
class = ""
|
||||
|
||||
id = ""
|
||||
name = "quantity"
|
||||
value = 0
|
||||
min = ""
|
||||
max = ""
|
||||
step = 1
|
||||
precision = "auto"
|
||||
|
||||
label = ""
|
||||
description = ""
|
||||
helpText = ""
|
||||
error = ""
|
||||
invalid = false
|
||||
|
||||
prefix = ""
|
||||
suffix = ""
|
||||
placeholder = ""
|
||||
autocomplete = "off"
|
||||
inputMode = "decimal"
|
||||
ariaLabel = ""
|
||||
|
||||
required = false
|
||||
disabled = false
|
||||
inputDisabled = false
|
||||
buttonsDisabled = false
|
||||
readonly = false
|
||||
allowInput = true
|
||||
keyboard = true
|
||||
wheel = false
|
||||
clamp = true
|
||||
fullWidth = false
|
||||
showButtons = true
|
||||
showValidationMessage = true
|
||||
|
||||
decrementLabel = "Decrease value"
|
||||
incrementLabel = "Increase value"
|
||||
controlsLabel = "Quantity controls"
|
||||
requiredMessage = "A value is required."
|
||||
minMessage = "Value is below the minimum."
|
||||
maxMessage = "Value is above the maximum."
|
||||
|
||||
@event input = function
|
||||
@event change = function
|
||||
@event increment = function
|
||||
@event decrement = function
|
||||
}
|
||||
|
||||
state currentValue = value
|
||||
state committedValue = value
|
||||
|
||||
functions {
|
||||
function inputId() {
|
||||
if (id !== "") {
|
||||
return id
|
||||
}
|
||||
|
||||
if (name !== "") {
|
||||
return name
|
||||
}
|
||||
|
||||
return "input-number"
|
||||
}
|
||||
|
||||
function descriptionId() {
|
||||
return inputId() + "-description"
|
||||
}
|
||||
|
||||
function messageId() {
|
||||
return inputId() + "-message"
|
||||
}
|
||||
|
||||
function componentColor() {
|
||||
if (color === "secondary") {
|
||||
return "var(--wire-color-secondary)"
|
||||
}
|
||||
|
||||
if (color === "success") {
|
||||
return "var(--wire-color-success)"
|
||||
}
|
||||
|
||||
if (color === "warning") {
|
||||
return "var(--wire-color-warning)"
|
||||
}
|
||||
|
||||
if (color === "danger") {
|
||||
return "var(--wire-color-danger)"
|
||||
}
|
||||
|
||||
if (color === "info") {
|
||||
return "var(--wire-color-info)"
|
||||
}
|
||||
|
||||
return "var(--wire-color-primary)"
|
||||
}
|
||||
|
||||
function hasMin() {
|
||||
return min !== "" && min !== null && min !== undefined
|
||||
}
|
||||
|
||||
function hasMax() {
|
||||
return max !== "" && max !== null && max !== undefined
|
||||
}
|
||||
|
||||
function isBlank() {
|
||||
return currentValue === "" || currentValue === null || currentValue === undefined
|
||||
}
|
||||
|
||||
function normalizedStep() {
|
||||
return Number(step) > 0 ? Number(step) : 1
|
||||
}
|
||||
|
||||
function inferredPrecision() {
|
||||
if (precision !== "auto" && precision !== "") {
|
||||
return Math.max(0, Number(precision) || 0)
|
||||
}
|
||||
|
||||
if (String(normalizedStep()).includes(".")) {
|
||||
return String(normalizedStep()).split(".")[1].length
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function precisionFactor() {
|
||||
return Math.pow(10, inferredPrecision())
|
||||
}
|
||||
|
||||
function roundValue(nextValue) {
|
||||
return Math.round(Number(nextValue) * precisionFactor()) / precisionFactor()
|
||||
}
|
||||
|
||||
function clampValue(nextValue) {
|
||||
nextValue = Number(nextValue)
|
||||
|
||||
if (hasMin() && nextValue < Number(min)) {
|
||||
nextValue = Number(min)
|
||||
}
|
||||
|
||||
if (hasMax() && nextValue > Number(max)) {
|
||||
nextValue = Number(max)
|
||||
}
|
||||
|
||||
return roundValue(nextValue)
|
||||
}
|
||||
|
||||
function isBelowMin() {
|
||||
return !isBlank() && hasMin() && Number(currentValue) < Number(min)
|
||||
}
|
||||
|
||||
function isAboveMax() {
|
||||
return !isBlank() && hasMax() && Number(currentValue) > Number(max)
|
||||
}
|
||||
|
||||
function isInvalid() {
|
||||
return (
|
||||
invalid ||
|
||||
error !== "" ||
|
||||
(required && isBlank()) ||
|
||||
isBelowMin() ||
|
||||
isAboveMax()
|
||||
)
|
||||
}
|
||||
|
||||
function validationMessage() {
|
||||
if (error !== "") {
|
||||
return error
|
||||
}
|
||||
|
||||
if (required && isBlank()) {
|
||||
return requiredMessage
|
||||
}
|
||||
|
||||
if (isBelowMin()) {
|
||||
return minMessage
|
||||
}
|
||||
|
||||
if (isAboveMax()) {
|
||||
return maxMessage
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function hasMessage() {
|
||||
return (
|
||||
helpText !== "" ||
|
||||
(showValidationMessage && isInvalid() && validationMessage() !== "")
|
||||
)
|
||||
}
|
||||
|
||||
function describedBy() {
|
||||
if (description !== "" && hasMessage()) {
|
||||
return descriptionId() + " " + messageId()
|
||||
}
|
||||
|
||||
if (description !== "") {
|
||||
return descriptionId()
|
||||
}
|
||||
|
||||
if (hasMessage()) {
|
||||
return messageId()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function decrementDisabled() {
|
||||
return (
|
||||
disabled ||
|
||||
readonly ||
|
||||
buttonsDisabled ||
|
||||
(hasMin() && !isBlank() && Number(currentValue) <= Number(min))
|
||||
)
|
||||
}
|
||||
|
||||
function incrementDisabled() {
|
||||
return (
|
||||
disabled ||
|
||||
readonly ||
|
||||
buttonsDisabled ||
|
||||
(hasMax() && !isBlank() && Number(currentValue) >= Number(max))
|
||||
)
|
||||
}
|
||||
|
||||
function dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
eventName,
|
||||
action,
|
||||
previousValue,
|
||||
root,
|
||||
customEvent
|
||||
) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")
|
||||
|
||||
if (!root && sourceEvent.target) {
|
||||
root = sourceEvent.target.closest("[data-wrn-input-number]")
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent(eventName, true, false, {
|
||||
component: "InputNumber",
|
||||
name: name,
|
||||
value: currentValue,
|
||||
previousValue: previousValue,
|
||||
action: action,
|
||||
min: hasMin() ? Number(min) : null,
|
||||
max: hasMax() ? Number(max) : null,
|
||||
step: normalizedStep(),
|
||||
valid: !isInvalid()
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
function applyControlValue(nextValue, action, sourceEvent, previousValue) {
|
||||
previousValue = currentValue
|
||||
currentValue = clampValue(nextValue)
|
||||
committedValue = currentValue
|
||||
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"input",
|
||||
action,
|
||||
previousValue
|
||||
)
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"change",
|
||||
action,
|
||||
previousValue
|
||||
)
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
action,
|
||||
action,
|
||||
previousValue
|
||||
)
|
||||
}
|
||||
|
||||
function incrementValue(sourceEvent, nextValue) {
|
||||
if (incrementDisabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isBlank()) {
|
||||
nextValue = hasMin() ? Number(min) : normalizedStep()
|
||||
} else {
|
||||
nextValue =
|
||||
Number(currentValue) +
|
||||
normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
|
||||
}
|
||||
|
||||
applyControlValue(nextValue, "increment", sourceEvent)
|
||||
}
|
||||
|
||||
function decrementValue(sourceEvent, nextValue) {
|
||||
if (decrementDisabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isBlank()) {
|
||||
nextValue = hasMax() ? Number(max) : -normalizedStep()
|
||||
} else {
|
||||
nextValue =
|
||||
Number(currentValue) -
|
||||
normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
|
||||
}
|
||||
|
||||
applyControlValue(nextValue, "decrement", sourceEvent)
|
||||
}
|
||||
|
||||
function handleInput(sourceEvent, previousValue, nextValue) {
|
||||
sourceEvent.stopPropagation()
|
||||
previousValue = currentValue
|
||||
nextValue = sourceEvent.target.value
|
||||
|
||||
if (nextValue === "") {
|
||||
currentValue = ""
|
||||
} else if (!Number.isNaN(Number(nextValue))) {
|
||||
currentValue = roundValue(Number(nextValue))
|
||||
}
|
||||
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"input",
|
||||
"input",
|
||||
previousValue
|
||||
)
|
||||
}
|
||||
|
||||
function handleChange(sourceEvent, previousValue) {
|
||||
sourceEvent.stopPropagation()
|
||||
previousValue = committedValue
|
||||
|
||||
if (!isBlank()) {
|
||||
currentValue = clamp
|
||||
? clampValue(currentValue)
|
||||
: roundValue(currentValue)
|
||||
}
|
||||
|
||||
committedValue = currentValue
|
||||
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"change",
|
||||
"change",
|
||||
previousValue
|
||||
)
|
||||
}
|
||||
|
||||
function handleKeydown(sourceEvent) {
|
||||
if (
|
||||
!keyboard ||
|
||||
disabled ||
|
||||
readonly ||
|
||||
inputDisabled ||
|
||||
!allowInput
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceEvent.key === "ArrowUp") {
|
||||
sourceEvent.preventDefault()
|
||||
incrementValue(sourceEvent)
|
||||
} else if (sourceEvent.key === "ArrowDown") {
|
||||
sourceEvent.preventDefault()
|
||||
decrementValue(sourceEvent)
|
||||
} else if (sourceEvent.key === "Home" && hasMin()) {
|
||||
sourceEvent.preventDefault()
|
||||
applyControlValue(Number(min), "decrement", sourceEvent)
|
||||
} else if (sourceEvent.key === "End" && hasMax()) {
|
||||
sourceEvent.preventDefault()
|
||||
applyControlValue(Number(max), "increment", sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
function handleWheel(sourceEvent) {
|
||||
if (
|
||||
!wheel ||
|
||||
disabled ||
|
||||
readonly ||
|
||||
inputDisabled ||
|
||||
!allowInput
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
sourceEvent.preventDefault()
|
||||
|
||||
if (sourceEvent.deltaY < 0) {
|
||||
incrementValue(sourceEvent)
|
||||
} else if (sourceEvent.deltaY > 0) {
|
||||
decrementValue(sourceEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-wrn-input-number
|
||||
data-variant='{variant}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-value='{currentValue}'
|
||||
data-invalid='{isInvalid() ? "true" : "false"}'
|
||||
data-disabled='{disabled ? "true" : "false"}'
|
||||
style='--input-number-accent: {componentColor()};'
|
||||
class='relative flex flex-col gap-1.5 text-[var(--wire-color-text)] {fullWidth ? "w-full" : variant === "compact" ? "w-fit max-w-full" : "w-full max-w-sm"} {disabled ? "opacity-60" : ""} {class}'
|
||||
>
|
||||
{#if label !== "" && variant !== "labeled" && variant !== "seat"}
|
||||
<label
|
||||
for='{inputId()}'
|
||||
class='inline-flex items-center gap-1 text-sm font-semibold leading-5 text-[var(--wire-color-text)]'
|
||||
>
|
||||
<span>{label}</span>
|
||||
{#if required}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='text-[var(--wire-color-danger)]'
|
||||
>*</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if description !== "" && variant !== "labeled" && variant !== "seat"}
|
||||
<p
|
||||
id='{descriptionId()}'
|
||||
class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class='group flex min-w-0 overflow-hidden border bg-[var(--wire-color-surface)] text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] transition-[border-color,box-shadow,background-color] duration-[var(--wire-motion-base)] ease-[var(--wire-ease-standard)] focus-within:border-[var(--input-number-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--input-number-accent)_18%,transparent)] {variant === "compact" ? "rounded-full" : "rounded-[var(--wire-radius-sm)]"} {size === "xs" ? "min-h-8 text-xs" : size === "sm" ? "min-h-9 text-sm" : size === "lg" ? "min-h-12 text-base" : size === "xl" ? "min-h-14 text-lg" : "min-h-10 text-sm"} {isInvalid() ? "border-[var(--wire-color-danger)] focus-within:border-[var(--wire-color-danger)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--wire-color-danger)_18%,transparent)]" : "border-[var(--wire-color-border)]"} {disabled ? "cursor-not-allowed bg-[var(--wire-color-surface-2)]" : ""}'
|
||||
>
|
||||
{#if variant === "horizontal" && showButtons}
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{decrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{decrementDisabled()}'
|
||||
@click='decrementValue(event)'
|
||||
class='inline-flex shrink-0 items-center justify-center border-r border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--minus] size-4'
|
||||
></span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class='flex min-w-0 flex-1 {variant === "labeled" ? "flex-col items-stretch justify-center gap-0.5" : variant === "seat" ? "items-center justify-between gap-3" : "items-center"} {size === "xs" ? "px-2" : size === "sm" ? "px-2.5" : size === "lg" ? "px-4" : size === "xl" ? "px-5" : "px-3"}'
|
||||
>
|
||||
{#if variant === "labeled" || variant === "seat"}
|
||||
<div class='min-w-0 flex-1'>
|
||||
{#if label !== ""}
|
||||
<label
|
||||
for='{inputId()}'
|
||||
class='block truncate font-semibold leading-4 text-[var(--wire-color-text)] {variant === "labeled" ? "text-xs font-medium text-[var(--wire-color-muted)]" : "text-sm"}'
|
||||
>
|
||||
{label}
|
||||
{#if required}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='ml-0.5 text-[var(--wire-color-danger)]'
|
||||
>*</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if description !== ""}
|
||||
<span
|
||||
id='{descriptionId()}'
|
||||
class='block truncate text-xs leading-4 text-[var(--wire-color-muted)]'
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class='flex min-w-0 items-center {variant === "seat" ? "w-auto shrink-0" : "w-full"}'
|
||||
>
|
||||
{#if prefix !== ""}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='shrink-0 pr-1.5 text-[var(--wire-color-muted)]'
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id='{inputId()}'
|
||||
name='{name}'
|
||||
type="number"
|
||||
value='{currentValue}'
|
||||
min='{min}'
|
||||
max='{max}'
|
||||
step='{normalizedStep()}'
|
||||
placeholder='{placeholder}'
|
||||
autocomplete='{autocomplete}'
|
||||
inputmode='{inputMode}'
|
||||
aria-label='{ariaLabel !== "" ? ariaLabel : label !== "" ? label : name}'
|
||||
aria-describedby='{describedBy()}'
|
||||
aria-invalid='{isInvalid() ? "true" : "false"}'
|
||||
aria-required='{required ? "true" : "false"}'
|
||||
aria-disabled='{disabled || inputDisabled ? "true" : "false"}'
|
||||
required='{required}'
|
||||
disabled='{disabled}'
|
||||
readonly='{readonly || inputDisabled || !allowInput}'
|
||||
tabindex='{inputDisabled ? "-1" : "0"}'
|
||||
@input='handleInput(event)'
|
||||
@change='handleChange(event)'
|
||||
@keydown='handleKeydown(event)'
|
||||
@wheel='handleWheel(event)'
|
||||
class='min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-medium leading-none text-[var(--wire-color-text)] outline-none placeholder:text-[var(--wire-color-muted)] read-only:cursor-default disabled:cursor-not-allowed [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {variant === "horizontal" || variant === "compact" || variant === "seat" ? "text-center" : "text-left"} {variant === "seat" ? "w-10 flex-none" : "w-full"}'
|
||||
/>
|
||||
|
||||
{#if suffix !== ""}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='shrink-0 pl-1.5 text-[var(--wire-color-muted)]'
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if variant === "horizontal" && showButtons}
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{incrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{incrementDisabled()}'
|
||||
@click='incrementValue(event)'
|
||||
class='inline-flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--plus] size-4'
|
||||
></span>
|
||||
</button>
|
||||
{:else}
|
||||
{#if showButtons}
|
||||
<div
|
||||
role="group"
|
||||
aria-label='{controlsLabel}'
|
||||
class='flex shrink-0 border-l border-[var(--wire-color-border)] {variant === "vertical" ? "flex-col" : "flex-row"}'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{decrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{decrementDisabled()}'
|
||||
@click='decrementValue(event)'
|
||||
class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1 border-b border-[var(--wire-color-border)]" : "border-r border-[var(--wire-color-border)]"} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--minus] size-4'
|
||||
></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{incrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{incrementDisabled()}'
|
||||
@click='incrementValue(event)'
|
||||
class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1" : ""} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--plus] size-4'
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showValidationMessage && isInvalid() && validationMessage() !== ""}
|
||||
<p
|
||||
id='{messageId()}'
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class='m-0 flex items-center gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)]'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--circle-alert] size-3.5 shrink-0'
|
||||
></span>
|
||||
<span>{validationMessage()}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if (!showValidationMessage || !isInvalid() || validationMessage() === "") && helpText !== ""}
|
||||
<p
|
||||
id='{messageId()}'
|
||||
class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
|
||||
>
|
||||
{helpText}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/ui — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -26,7 +26,7 @@
|
||||
"./ui.css": "./ui.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/uploader — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.3.6"
|
||||
"@wrnexus/core": "^0.4.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/validation — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# WRNexusJS CAPTCHA audio playback fix
|
||||
|
||||
This patch fixes audio CAPTCHA playback in production and bundled server builds.
|
||||
|
||||
## Root cause
|
||||
|
||||
`AssetAudioRenderer` originally located WAV files only from `import.meta.url`. After
|
||||
WRNexusJS bundles the server into `dist/server.js`, that URL points to the app build
|
||||
folder instead of `packages/captcha`, so `/api/captcha/audio/...` fails while trying
|
||||
to read the WAV clips.
|
||||
|
||||
## Changes
|
||||
|
||||
- Resolves audio assets from the installed `@wrnexus/captcha/audio` package entry.
|
||||
- Supports `WRNEXUS_CAPTCHA_AUDIO_DIR` for custom deployments.
|
||||
- Searches workspace and `node_modules` layouts as safe fallbacks.
|
||||
- Keeps the browser `Audio` instance alive while it plays.
|
||||
- Stops old audio when a new challenge is loaded.
|
||||
- Returns a clear HTTP 503 and server log if audio rendering fails.
|
||||
- Adds a real WAV rendering test.
|
||||
|
||||
## Apply
|
||||
|
||||
Extract this ZIP into the WRNexusJS repository root and replace the included files.
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test packages/captcha
|
||||
bun run --cwd examples/captcha-showcase check
|
||||
```
|
||||
|
||||
Restart the server. If running the production build, rebuild first:
|
||||
|
||||
```bash
|
||||
bun run --cwd examples/captcha-showcase build
|
||||
bun examples/captcha-showcase/dist/server.js
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
1. Open the browser Network panel.
|
||||
2. Click **Listen**.
|
||||
3. The `/api/captcha/audio/<id>?key=...` request must return HTTP 200.
|
||||
4. Its `content-type` must be `audio/wav`.
|
||||
|
||||
For a nonstandard package layout, set an absolute path before starting the server:
|
||||
|
||||
```powershell
|
||||
$env:WRNEXUS_CAPTCHA_AUDIO_DIR="E:\WireJS\packages\captcha\assets\audio"
|
||||
```
|
||||
@@ -1,40 +0,0 @@
|
||||
# WRNexusJS CAPTCHA Fixed 8
|
||||
|
||||
Extract this ZIP directly into the WRNexusJS repository root and allow the included files to replace the current CAPTCHA files.
|
||||
|
||||
## Added
|
||||
|
||||
- 18 concrete generated CAPTCHA image styles
|
||||
- `imageStyle="random"`
|
||||
- `allowedStyles` and `excludedStyles`
|
||||
- `randomizeStyle`
|
||||
- Resolved style metadata and browser event details
|
||||
- Easy, normal, hard, controlled-pool, and full renderer showcase at `/styles`
|
||||
- Tailwind Iconify/Lucide showcase dependencies
|
||||
- Faster CRC32 PNG encoding
|
||||
- Safe floating-point line rendering for web, darts, distortion, wave, and scribble effects
|
||||
- Tests for every renderer and style-pool rule
|
||||
|
||||
## Supported styles
|
||||
|
||||
`classic`, `collision`, `snow`, `corrosion`, `spiderweb`, `cross-shadow`, `split`, `split2`, `cut`, `darts`, `distortion`, `stitch`, `striped`, `wave`, `grid-noise`, `scribble`, `pixel`, `broken-lines`, and `random`.
|
||||
|
||||
## After extraction
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run scripts/generate-ui-component-reference.mjs
|
||||
bun run typecheck
|
||||
bun test packages/captcha
|
||||
bun test services/managed-captcha
|
||||
bun run --cwd examples/captcha-showcase check
|
||||
bun run --cwd examples/captcha-showcase dev
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:3000/styles
|
||||
```
|
||||
|
||||
Use a hard browser refresh after restarting the development server.
|
||||
@@ -1,51 +0,0 @@
|
||||
# WRNexusJS CAPTCHA Fixed 7
|
||||
|
||||
Extract this patch directly into the WRNexusJS repository root and allow files to be replaced.
|
||||
|
||||
## Added
|
||||
|
||||
- `size="compact"`, `size="normal"`, and `size="big"`
|
||||
- Backward-compatible `small`/`sm`, `large`/`lg`, and `compact="true"` aliases
|
||||
- `showListen="true|false"` for the Listen button
|
||||
- Existing `showAudio="true|false"` remains the broader audio-alternative switch
|
||||
- Self-hosted `type="not-robot"` checkbox challenge
|
||||
- Showcase examples and tests for all new behavior
|
||||
- Managed API/OpenAPI support for `not-robot`
|
||||
|
||||
## Usage
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="number"
|
||||
action="compact-example"
|
||||
size="compact"
|
||||
showListen="false"
|
||||
/>
|
||||
```
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="not-robot"
|
||||
action="contact-submit"
|
||||
size="compact"
|
||||
showListen="false"
|
||||
/>
|
||||
```
|
||||
|
||||
The not-robot checkbox is server verified. It uses a one-time challenge, minimum completion time, honeypot, expiry, attempt limits, action binding, optional hostname/session/IP binding, and a single-use response token. It is a low-friction mode; high-risk traffic should escalate to a visual challenge or an external provider.
|
||||
|
||||
## Regenerate and test
|
||||
|
||||
```bash
|
||||
bun run scripts/generate-ui-component-reference.mjs
|
||||
bun run typecheck
|
||||
bun test packages/captcha
|
||||
bun run --cwd examples/captcha-showcase check
|
||||
bun run --cwd examples/captcha-showcase dev
|
||||
```
|
||||
|
||||
Hard-refresh the browser after restarting the development server.
|
||||
@@ -1,9 +0,0 @@
|
||||
tsconfig.json
|
||||
packages/styles/src/config.ts
|
||||
packages/dev-server/package.json
|
||||
packages/dev-server/src/hmr.ts
|
||||
packages/dev-server/src/watch.ts
|
||||
packages/dev-server/src/runtime.ts
|
||||
packages/dev-server/src/index.ts
|
||||
packages/dev-server/src/serve-entry.ts
|
||||
packages/dev-toolbar/src/client/runtime.ts
|
||||
@@ -0,0 +1,37 @@
|
||||
# WRNexusJS 0.4.0
|
||||
|
||||
## Package platform
|
||||
|
||||
- Added automatic package plugin discovery from application dependencies and workspaces.
|
||||
- Added package-owned components, client runtimes, assets, styles, routes, middleware, database migrations, and DevToolbar panels.
|
||||
- Added duplicate/collision validation and strict discovery in development, build, doctor, and inspect flows.
|
||||
- Added production content-hashed client runtimes, content-addressed package asset storage with stable public URLs, and page-scoped runtime injection.
|
||||
- Added development bundling for TypeScript package runtimes and package-source HMR.
|
||||
- Added CSR package runtime mount/unmount lifecycle.
|
||||
|
||||
## CLI and diagnostics
|
||||
|
||||
- Added `wrnexus inspect packages|plugins|routes|assets|runtimes|styles|migrations|bundle`.
|
||||
- Added `wrnexus generate system <name>`.
|
||||
- Expanded production build reports with all route types, plugins, runtimes, assets, component directories, and package migrations.
|
||||
- Added the 0.4 application migration and release verification scripts.
|
||||
- Fixed duplicate historical migration IDs.
|
||||
|
||||
## CAPTCHA
|
||||
|
||||
- Removed manual `captcha.js` copying and script inclusion.
|
||||
- Removed duplicated CAPTCHA components from `@wrnexus/ui` and the showcase application.
|
||||
- CAPTCHA is now discovered from `@wrnexus/captcha` and injected only on pages that render it.
|
||||
- Preserved all challenge modes, audio, protected-page flow, validated form, compact/normal/big modes, Listen controls, not-robot mode, disturbance controls, and image-style renderers.
|
||||
|
||||
## Existing packages
|
||||
|
||||
Every existing package received an exported advanced capability layer. See `docs/PACKAGE-UPGRADES-0.4.md` for the package-by-package inventory.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Package runtime and asset path collisions now fail before serving.
|
||||
- Production style processing fails closed by default.
|
||||
- Page ETags now include structured script metadata.
|
||||
- Package `.wrn` changes invalidate Tailwind output during HMR.
|
||||
- Package migrations use the same ordered migration contract as application migrations.
|
||||
@@ -0,0 +1,109 @@
|
||||
# WRNexusJS 0.4.0 upgrade manifest
|
||||
|
||||
This replacement upgrades the uploaded WRNexusJS repository to the 0.4.0 platform baseline and preserves existing APIs where practical.
|
||||
|
||||
Compared with the uploaded baseline archive, the final tree contains 45 added files, 214 modified files, and 15 removed obsolete or generated files.
|
||||
|
||||
## Main platform additions
|
||||
|
||||
- Automatic installed/workspace package discovery through `package.json` `wrnexus.plugin`
|
||||
- Package-owned component directories
|
||||
- Page-scoped client runtimes
|
||||
- Virtual development assets with secure content types
|
||||
- Hashed production runtime assets
|
||||
- Package CSS entries and Tailwind scan sources
|
||||
- Package page/API/realtime routes and middleware
|
||||
- Package database migrations
|
||||
- Package DevToolbar panels
|
||||
- Dependency ordering, duplicate detection, cycle detection, and safe contribution validation
|
||||
- Runtime mount/unmount lifecycle during CSR navigation
|
||||
- Package-aware HMR and build reporting
|
||||
- `wrnexus inspect` commands and `wrnexus generate system`
|
||||
- 0.4 migration and compatibility tooling
|
||||
|
||||
## CAPTCHA integration
|
||||
|
||||
`@wrnexus/captcha` is now self-contained and automatically discovered. The old duplicate copies are removed from `@wrnexus/ui` and the showcase. The package contributes its own component, runtime, Tailwind scan source, and DevToolbar audit.
|
||||
|
||||
Developers use:
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/captcha
|
||||
```
|
||||
|
||||
```wrn
|
||||
<Captcha type="alphanumeric" action="contact-submit" />
|
||||
```
|
||||
|
||||
They do not copy `captcha.js`, add a script tag, or manually register `captchaPlugin()` for normal use. The runtime is injected only on pages that render a CAPTCHA.
|
||||
|
||||
## Existing packages upgraded
|
||||
|
||||
| Package | 0.4.0 addition or hardening |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `@wrnexus/syntax` | syntax contract, feature/version metadata, ranges and diagnostics |
|
||||
| `@wrnexus/compiler` | compilation cache, dependency graph, cycle-safe cache implementation |
|
||||
| `@wrnexus/reactive` | watchers, resources, abort/supersession handling and cleanup scopes |
|
||||
| `@wrnexus/core` | service container, lifecycle, health, Problem Details, request IDs and idempotency |
|
||||
| `@wrnexus/router` | named routes, manifests, reverse URLs and app-over-package precedence |
|
||||
| `@wrnexus/ssr` | structured scripts, deduplication, CSP/SRI attributes and streaming output |
|
||||
| `@wrnexus/csr` | lazy package runtime loading and navigation mount/unmount lifecycle |
|
||||
| `@wrnexus/plugin` | discovery and package components/runtimes/assets/styles/routes/middleware/migrations |
|
||||
| `@wrnexus/dev-server` | virtual assets, TS runtime bundling, package HMR and migration integration |
|
||||
| `@wrnexus/cli` | package-aware builds, inspect commands, generator and 0.4 migration |
|
||||
| `@wrnexus/dev-toolbar` | package panels and platform snapshots |
|
||||
| `@wrnexus/styles` | package CSS/source support, package-only styles, token and contrast audits |
|
||||
| `@wrnexus/test` | request/context utilities, JSON/problem assertions, cookies and deferred helpers |
|
||||
| `@wrnexus/db` | migration ordering, cursor pagination, optimistic updates and tenant helpers |
|
||||
| `@wrnexus/queue` | durable stores, claims, priority, retry, dead-letter and processing safety |
|
||||
| `@wrnexus/pubsub` | resilient delivery and expiring defensive presence channels |
|
||||
| `@wrnexus/uploader` | safe object keys, inspection, policy enforcement and signed file tokens |
|
||||
| `@wrnexus/encryption` | versioned payloads and validated rotatable keyrings |
|
||||
| `@wrnexus/jwt` | key-ID signing, validated keyrings and safe decoding |
|
||||
| `@wrnexus/oauth` | state/PKCE storage, refresh, OIDC discovery and safe return URLs |
|
||||
| `@wrnexus/authz` | explainable decisions, owner policies, composition and filtering |
|
||||
| `@wrnexus/captcha` | automatic component/runtime, providers, audio, image styles, gates and guards |
|
||||
| `@wrnexus/validation` | async/cross-field refinements and OpenAPI conversion |
|
||||
| `@wrnexus/i18n` | fallbacks, coverage, formatters and plural messages |
|
||||
| `@wrnexus/ui` | component metadata and reference audits |
|
||||
| `@wrnexus/helpers` | retry, backoff, abortable sleep, timeouts and stable serialization |
|
||||
| `@wrnexus/tracking` | sampled/batched telemetry and reliable sink handling |
|
||||
| `@wrnexus/ai` | provider-neutral client, fallback providers and usage metadata |
|
||||
| `@wrnexus/mobile` | deep links, offline tasks and environment detection |
|
||||
| `@wrnexus/native` | capability manifests, permissions and target inspection |
|
||||
|
||||
## Upgrade and release tooling
|
||||
|
||||
- Root/package/service versions: 0.4.0
|
||||
- VS Code extension version: 0.4.0
|
||||
- Workspace lock versions updated
|
||||
- `update.ts` includes a 0.4.0 migration
|
||||
- Legacy CAPTCHA scripts are archived by migration rather than silently destroyed
|
||||
- Obsolete generated/release artifacts removed
|
||||
- New commands:
|
||||
|
||||
```bash
|
||||
bun run verify:0.4
|
||||
bun run validate:0.4
|
||||
wrnexus doctor
|
||||
wrnexus inspect packages .
|
||||
wrnexus inspect plugins .
|
||||
wrnexus inspect routes .
|
||||
wrnexus inspect runtimes .
|
||||
wrnexus inspect assets .
|
||||
wrnexus generate system <name>
|
||||
```
|
||||
|
||||
## Documentation included
|
||||
|
||||
- `README.md`
|
||||
- `CHANGELOG-0.4.0.md`
|
||||
- `docs/ARCHITECTURE-0.4.md`
|
||||
- `docs/PACKAGE-RUNTIMES-0.4.md`
|
||||
- `docs/PACKAGE-UPGRADES-0.4.md`
|
||||
- `docs/UPGRADE-0.4.md`
|
||||
- `docs/TEST-CHECKLIST-0.4.md`
|
||||
- `VALIDATION-0.4.0.md`
|
||||
- `MANIFEST-0.4.0.md`
|
||||
|
||||
See `VALIDATION-0.4.0.md` for the exact checks completed and the required final Bun validation commands.
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
# Bundle manifest
|
||||
|
||||
## Package
|
||||
|
||||
- Engine, challenge generators, optimized PNG renderer, audio renderer
|
||||
- Memory, SQLite-compatible, and Redis-compatible stores
|
||||
- Self-hosted, managed, Turnstile, reCAPTCHA, hCaptcha, and custom providers
|
||||
- HTTP handlers, guards, page gates, adaptive policy, validation integration
|
||||
- Plugin and DevToolbar audit panel
|
||||
- Tailwind-only `Captcha.wrn` with compact/normal/big sizing, listen visibility, not-robot mode, 25–75 disturbance control, and generated image renderer props
|
||||
- English WAV audio assets
|
||||
- Unit tests for all 18 PNG renderers, style resolution, security behavior, and documentation
|
||||
|
||||
## Example
|
||||
|
||||
- All self-hosted challenge types, including the not-robot checkbox
|
||||
- Audio and invisible examples
|
||||
- Shared `@wrnexus/validation` contact schema with client and API validation
|
||||
- Protected form API using `captchaGuard`
|
||||
- Working once-per-session protected-page grant and safe return redirect
|
||||
- Compact, normal, and big component examples
|
||||
- Listen-visible and listen-hidden examples
|
||||
- Easy, normal, and hard image-disturbance examples
|
||||
- Dedicated `/styles` gallery with 18 concrete renderers, random selection, allowed pools, and excluded pools
|
||||
- External-provider placeholders
|
||||
|
||||
## Managed service
|
||||
|
||||
- Project keys, allowed hostnames, quotas, key rotation
|
||||
- Public create/solve and secret-authenticated verification routes
|
||||
- Memory reference store, SQL migration, OpenAPI specification
|
||||
|
||||
## Integration
|
||||
|
||||
- Repeat-safe installer
|
||||
- TypeScript path aliases
|
||||
- Current component-discovery compatibility copy
|
||||
- Generated 18-style preview gallery and renderer guidance
|
||||
@@ -1,39 +1,21 @@
|
||||
# WRNexusJS CAPTCHA complete bundle
|
||||
# WRNexusJS 0.4.0 lint fix
|
||||
|
||||
This bundle targets WRNexusJS `main-80` / package version `0.3.6` and contains:
|
||||
Extract this archive into the WRNexusJS repository root and replace the included files.
|
||||
|
||||
- `packages/captcha`: publishable `@wrnexus/captcha` package
|
||||
- `packages/captcha/components/Captcha.wrn`: Tailwind-only UI component
|
||||
- `examples/captcha-showcase`: runnable challenge, disturbance, validated-form, and protected-page showcase
|
||||
- `services/managed-captcha`: managed API starter with projects, keys, hostname restrictions, quotas, and rotation
|
||||
- `scripts/install-captcha.mjs`: repeat-safe repository installer
|
||||
- `integration`: integration notes and generated path changes
|
||||
- `docs/IMAGE-STYLES.md`: renderer catalog with a generated preview gallery
|
||||
Fixes:
|
||||
|
||||
## Install into a WRNexusJS checkout
|
||||
- Removes the `no-useless-assignment` failure in the CAPTCHA page-grant endpoint.
|
||||
- Restores the Button showcase's specialized examples so `buttonUses` is used.
|
||||
- Declares browser globals for the package-owned CAPTCHA runtime.
|
||||
- Replaces empty catch blocks with documented best-effort cleanup.
|
||||
- Fixes `prefer-const` in package discovery.
|
||||
- Removes unused plugin, style-audit, and UI-test bindings.
|
||||
- Uses the UI-reference verifier in the release workflow.
|
||||
- Declares Node globals correctly in installer and validation scripts.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bun scripts/install-captcha.mjs /path/to/WRNexusJS \
|
||||
--with-showcase \
|
||||
--with-managed-service \
|
||||
--check
|
||||
bun run lint
|
||||
bun run validate:0.4
|
||||
```
|
||||
|
||||
The installer copies the package, places `Captcha.wrn` in `packages/ui/components` for current component discovery, adds TypeScript aliases, optionally installs the example and managed service, and can run validation commands.
|
||||
|
||||
Read `packages/captcha/README.md`, `packages/captcha/SECURITY.md`, and `services/managed-captcha/README.md` before production deployment.
|
||||
|
||||
## Fixed 9 compact-size update
|
||||
|
||||
- Compact width is now capped at `max-w-xs` instead of `max-w-sm`.
|
||||
- Compact padding, gaps, image height, input height, buttons, icons, and text are smaller.
|
||||
- The subtitle and provider-credit line are hidden in compact mode.
|
||||
- Normal and big rendering remain unchanged.
|
||||
|
||||
## Fixed 8 integration updates
|
||||
|
||||
- Added 18 generated CAPTCHA image renderers plus `imageStyle="random"`.
|
||||
- Added allowed and excluded renderer pools, forced randomization, and resolved style metadata.
|
||||
- Added the `/styles` showcase page with every renderer and easy/normal/hard random examples.
|
||||
- Optimized PNG line drawing and CRC generation for fast challenge creation.
|
||||
- Retains compact/normal/big sizing, Listen controls, not-robot mode, page-gate, 25–75 disturbance, validated form, audio, and runtime compatibility fixes.
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# WRNexusJS DevToolbar Integration
|
||||
|
||||
Extract this ZIP directly over the WRNexusJS repository root and allow these files to be replaced.
|
||||
|
||||
## Replace files one by one
|
||||
|
||||
1. `tsconfig.json`
|
||||
- Adds workspace path aliases for `@wrnexus/dev-toolbar` exports.
|
||||
|
||||
2. `packages/styles/src/config.ts`
|
||||
- Adds `DevToolbarConfig` and `devToolbar` to `AppConfig`.
|
||||
|
||||
3. `packages/dev-server/package.json`
|
||||
- Confirms the workspace dependency on `@wrnexus/dev-toolbar`.
|
||||
|
||||
4. `packages/dev-server/src/hmr.ts`
|
||||
- Adds generic JSON broadcasting for toolbar events.
|
||||
|
||||
5. `packages/dev-server/src/watch.ts`
|
||||
- Clears stale source issues and triggers rescans for CSS changes.
|
||||
|
||||
6. `packages/dev-server/src/runtime.ts`
|
||||
- Serves toolbar routes.
|
||||
- Injects the toolbar only in development HTML.
|
||||
- Collects route, component, layout, and request errors.
|
||||
- Sends HMR/browser events used by the toolbar.
|
||||
|
||||
7. `packages/dev-server/src/index.ts`
|
||||
- Resolves toolbar configuration.
|
||||
- Creates the server issue collector.
|
||||
- Connects collector updates to HMR.
|
||||
- Passes the toolbar into runtime and watcher dependencies.
|
||||
|
||||
8. `packages/dev-server/src/serve-entry.ts`
|
||||
- Passes `config.devToolbar` from `wrnexus.config.ts` into the server.
|
||||
|
||||
9. `packages/dev-toolbar/src/client/runtime.ts`
|
||||
- Loads server-side issues.
|
||||
- Receives live toolbar issue/scan messages.
|
||||
- Rescans after HMR and client navigation.
|
||||
|
||||
## Configuration
|
||||
|
||||
The toolbar is enabled automatically in development. Optional configuration:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
devToolbar: {
|
||||
enabled: true,
|
||||
position: "bottom-center",
|
||||
defaultOpen: false,
|
||||
scanOnNavigation: true,
|
||||
scanOnHmr: true,
|
||||
openEditor: true,
|
||||
editor: "code",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Disable it for an app:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
devToolbar: false,
|
||||
};
|
||||
```
|
||||
|
||||
## After replacement
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run format
|
||||
bun run typecheck
|
||||
bun run lint
|
||||
bun test
|
||||
bun run check
|
||||
```
|
||||
|
||||
The toolbar is development-only and is not injected when the server mode is `production`.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Reactive runtime compatibility correction
|
||||
|
||||
The original Captcha component attempted to execute full native JavaScript inside WRNexusJS behavior blocks. WRNexusJS 0.3.6 behavior blocks use a deliberately limited eval-free interpreter and do not execute arbitrary JavaScript syntax.
|
||||
|
||||
The corrected design keeps the `.wrn` component declarative and delegates network, audio, timers, provider SDKs, DOM updates, and form guards to a packaged same-origin browser runtime.
|
||||
|
||||
This removes the `Unexpected token '?'` mount failure and prevents the later failures that would have occurred for `async`, `await`, `try`, callback functions, constructors, and member assignments.
|
||||
@@ -0,0 +1,145 @@
|
||||
# WRNexusJS 0.4.0 validation report
|
||||
|
||||
Validation date: 2026-07-25
|
||||
|
||||
Baseline archive:
|
||||
|
||||
- File: `WRNexusJS-main-current.zip`
|
||||
- SHA-256: `7860543f1b0d8bb227857bb01f70c6a3719d51215e24990cc0b3e45a18211c50`
|
||||
|
||||
## Result
|
||||
|
||||
All validation that can be performed in this build environment passed. The repository is structurally aligned to 0.4.0, TypeScript source is syntactically and semantically valid under the project configuration used for validation, automatic package discovery works, CAPTCHA is loaded automatically without a copied `captcha.js`, editor checks pass, generated component documentation succeeds, and targeted runtime smoke checks pass.
|
||||
|
||||
Bun is not installed in this environment and external package installation is unavailable. Therefore the Bun unit-test suite, ESLint, Prettier, and real Bun production builds were not executed here. Run the final commands in the last section on the target Windows/Bun environment before publishing.
|
||||
|
||||
## Passed checks
|
||||
|
||||
### Repository structure
|
||||
|
||||
Command:
|
||||
|
||||
```bash
|
||||
node scripts/verify-0.4.mjs
|
||||
```
|
||||
|
||||
Passed:
|
||||
|
||||
- 30 `@wrnexus/*` packages aligned to version 0.4.0
|
||||
- Managed CAPTCHA service aligned to version 0.4.0
|
||||
- 73 unique updater migrations
|
||||
- Package/runtime/asset/migration discovery markers
|
||||
- CAPTCHA has no copied UI component or public JavaScript requirement
|
||||
- Bun workspace lock structure
|
||||
- VS Code extension package and lock versions
|
||||
- Required 0.4 architecture, migration, runtime, testing, and documentation files
|
||||
|
||||
### TypeScript
|
||||
|
||||
Passed:
|
||||
|
||||
- 346 TypeScript files transpiled with zero syntax diagnostics
|
||||
- Strict semantic TypeScript check completed with zero diagnostics
|
||||
- Compiler cache runtime-cycle smoke check passed
|
||||
|
||||
The semantic check used the repository `tsconfig.json` plus temporary environment-only type roots for Node/Bun declarations. The temporary config is not included in the delivered archives.
|
||||
|
||||
### WRN source
|
||||
|
||||
Passed:
|
||||
|
||||
- 211 `.wrn` files parsed successfully
|
||||
- CAPTCHA component parses without a manual `<script>` tag
|
||||
- Runtime marker `data-wrnexus-runtime="captcha"` is present
|
||||
|
||||
### VS Code extension
|
||||
|
||||
Passed:
|
||||
|
||||
- 23 Node test cases
|
||||
- Grammar, snippets, package metadata, language association, formatter, compiler, JSON, and Marketplace asset validation
|
||||
|
||||
### Generated UI documentation
|
||||
|
||||
Passed:
|
||||
|
||||
- 85 component detail pages generated
|
||||
- 325 live demos generated
|
||||
- 8 category pages generated
|
||||
|
||||
### JavaScript and JSON
|
||||
|
||||
Passed:
|
||||
|
||||
- 58 strict JSON files parsed
|
||||
- 28 JavaScript/MJS/CJS files passed `node --check`
|
||||
|
||||
### Runtime smoke checks
|
||||
|
||||
Passed targeted Node/TypeScript smoke checks for:
|
||||
|
||||
- Automatic discovery of `@wrnexus/captcha` from the application dependency list
|
||||
- Automatic CAPTCHA component, runtime, and style contributions
|
||||
- Runtime marker collection and one-time script injection
|
||||
- Correct JavaScript MIME type and `X-Content-Type-Options: nosniff`
|
||||
- Package migration resolution
|
||||
- Package style ordering
|
||||
- Package-only CSS wrapper generation without an application stylesheet
|
||||
- Application route precedence over package routes
|
||||
- Durable queue visibility during processing, retries, and dead letters
|
||||
- Telemetry batch requeue behavior with multiple failing sinks
|
||||
- Syntax version and source ranges
|
||||
- Service container, health registry, and idempotency store
|
||||
- Reactive watcher lifecycle, resource supersession, and abort behavior
|
||||
- Stable JSON with repeated references and circular-reference rejection
|
||||
- Retry and backoff helpers
|
||||
- PubSub validation and defensive presence values
|
||||
- Upload content sniffing, safe object keys, and signed file tokens
|
||||
- Encryption and JWT keyring duplicate/active-key validation and defensive copies
|
||||
- Theme token and contrast audits
|
||||
|
||||
## Important architecture verification
|
||||
|
||||
The CAPTCHA package now behaves as a framework-native package:
|
||||
|
||||
1. An application installs `@wrnexus/captcha`.
|
||||
2. WRNexusJS discovers `wrnexus.plugin` from the package manifest.
|
||||
3. `<Captcha />` is resolved from the package component directory.
|
||||
4. SSR records the `captcha` runtime marker only when the component is rendered.
|
||||
5. Development serves `/__wrnexus/assets/captcha.js` with the correct MIME type.
|
||||
6. Production emits a hashed runtime asset.
|
||||
7. SSR or CSR navigation loads the runtime once and runs its mount lifecycle.
|
||||
8. No user-authored script tag or copied public JavaScript file is required.
|
||||
|
||||
## Required final local validation
|
||||
|
||||
Run from the repository root with Bun 1.3.14 or newer:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run verify:0.4
|
||||
bun run validate:0.4
|
||||
```
|
||||
|
||||
`validate:0.4` runs:
|
||||
|
||||
```text
|
||||
structural verification
|
||||
full typecheck
|
||||
ESLint
|
||||
all package tests
|
||||
Prettier check
|
||||
integration tests
|
||||
basic application tests and production build
|
||||
component showcase check
|
||||
CAPTCHA showcase check
|
||||
managed CAPTCHA service tests
|
||||
```
|
||||
|
||||
For release preparation, also run:
|
||||
|
||||
```bash
|
||||
bun run release:prepare
|
||||
```
|
||||
|
||||
Do not publish if any local Bun, lint, format, test, build, or migration check fails. The included code fixes the issues found through available static and runtime validation, but no responsible validation process can guarantee that future environments or untested integrations contain zero defects.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Validation report
|
||||
|
||||
Validated in the artifact environment on 2026-07-25:
|
||||
|
||||
- Strict TypeScript checking passed for package source, managed-service source, and all included test files using local declarations for WRNexusJS/Bun APIs.
|
||||
- Self-hosted engine smoke tests passed: incorrect answer, correct answer, challenge consumption, one-use response token, replay rejection, and expiry.
|
||||
- Default generator smoke tests passed for number, alphabet, alphanumeric, calculation, generated-image selection, not-robot, invisible timing, PNG output, and bundled WAV rendering.
|
||||
- Managed-service smoke tests passed for project creation, hostname-restricted challenge creation, solving, secret-authenticated verification, and response-token consumption.
|
||||
- The installer passed first-install and repeated-install smoke tests, including TypeScript aliases, UI component placement, showcase installation, managed-service installation, and workspace updates.
|
||||
- All showcase `.wrn` pages parsed with the available WRNexusJS parser.
|
||||
- The `Captcha.wrn` body parsed after removing only the newer `@event` prop declarations; those declarations follow the same current syntax used by the framework’s InputNumber component.
|
||||
- Dynamic attribute expressions were checked for the framework’s required single-quoted form.
|
||||
- All used `--wire-*` variables were checked against the framework token set available in the repository snapshot.
|
||||
|
||||
Bun is not installed in this artifact environment, so the included Bun unit suite and full current-branch monorepo build were not executed here. Run the installer with `--check` in the current WRNexusJS checkout to execute component-reference generation, Bun tests, TypeScript checking, and the showcase build.
|
||||
|
||||
|
||||
## Fixed 8 checks
|
||||
|
||||
- Strict TypeScript checking passed after adding renderer types, style resolution, generated image effects, component props, runtime payloads, managed API fields, and showcase tests.
|
||||
- Every concrete renderer generated a valid PNG signature and a distinct image in deterministic smoke validation.
|
||||
- The renderer catalog contains 18 concrete styles plus `random`; allowed and excluded pools reject unknown or empty configurations.
|
||||
- Floating-point line endpoints now terminate correctly, including spiderweb, darts, wave, distortion, and scribble effects.
|
||||
- CRC32 encoding uses a lookup table; all 18 preview images generated in under one second in the artifact environment.
|
||||
- The component and browser runtime expose `imageStyle`, `allowedStyles`, `excludedStyles`, and `randomizeStyle`.
|
||||
- The `/styles` showcase page includes easy, normal, hard, controlled-pool, and every concrete renderer example.
|
||||
- Compact/normal/big sizing, Listen controls, not-robot, protected-page, 25–75 disturbance, validated-form, audio, and static-asset fixes remain included.
|
||||
@@ -1,195 +0,0 @@
|
||||
--- a/packages/compiler/src/codegen.ts
|
||||
+++ b/packages/compiler/src/codegen.ts
|
||||
@@ -859,7 +859,11 @@
|
||||
out.push(`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`);
|
||||
}
|
||||
if (clientLoads.length > 0) {
|
||||
- out.push(`export const __wrnexusClientLoad = ${JSON.stringify(clientLoads.map((entry) => entry.body))};`);
|
||||
+ out.push(
|
||||
+ `export async function __wrnexusClientLoad(ctx: any) {
|
||||
+${clientLoads.map((entry) => entry.body).join("\n")}
|
||||
+}`,
|
||||
+ );
|
||||
}
|
||||
}
|
||||
|
||||
--- a/packages/csr/src/reactive-runtime.ts
|
||||
+++ b/packages/csr/src/reactive-runtime.ts
|
||||
@@ -480,18 +480,39 @@
|
||||
// signal.subscribe dedupes, so re-subscribing each run is cheap and bounded.
|
||||
var currentRenderer = null;
|
||||
var pendingRenderers = new Set();
|
||||
- var renderScheduled = false;
|
||||
+ var batchDepth = 0;
|
||||
+ var flushingRenderers = false;
|
||||
+
|
||||
+ function flushRenderers() {
|
||||
+ if (flushingRenderers || batchDepth > 0) return;
|
||||
+
|
||||
+ flushingRenderers = true;
|
||||
+
|
||||
+ try {
|
||||
+ while (pendingRenderers.size > 0) {
|
||||
+ var queue = Array.from(pendingRenderers);
|
||||
+ pendingRenderers.clear();
|
||||
+ queue.forEach(function (run) { run(); });
|
||||
+ }
|
||||
+ } finally {
|
||||
+ flushingRenderers = false;
|
||||
+ }
|
||||
+ }
|
||||
|
||||
function scheduleRenderer(renderer) {
|
||||
pendingRenderers.add(renderer);
|
||||
- if (renderScheduled) return;
|
||||
- renderScheduled = true;
|
||||
- queueMicrotask(function () {
|
||||
- renderScheduled = false;
|
||||
- var queue = Array.from(pendingRenderers);
|
||||
- pendingRenderers.clear();
|
||||
- queue.forEach(function (run) { run(); });
|
||||
- });
|
||||
+ flushRenderers();
|
||||
+ }
|
||||
+
|
||||
+ function batchUpdates(callback) {
|
||||
+ batchDepth++;
|
||||
+
|
||||
+ try {
|
||||
+ return callback();
|
||||
+ } finally {
|
||||
+ batchDepth--;
|
||||
+ flushRenderers();
|
||||
+ }
|
||||
}
|
||||
|
||||
function reactive(fn) {
|
||||
@@ -723,68 +744,70 @@
|
||||
source,
|
||||
locals,
|
||||
) {
|
||||
- var statements =
|
||||
- splitStatements(source);
|
||||
+ return batchUpdates(function () {
|
||||
+ var statements =
|
||||
+ splitStatements(source);
|
||||
|
||||
- for (
|
||||
- var statementIndex = 0;
|
||||
- statementIndex <
|
||||
- statements.length;
|
||||
- statementIndex++
|
||||
- ) {
|
||||
- var result = runStatement(
|
||||
- statements[statementIndex],
|
||||
- function (expression) {
|
||||
- return evalExpr(
|
||||
- expression,
|
||||
- locals,
|
||||
- );
|
||||
- },
|
||||
- function (name) {
|
||||
- if (
|
||||
- locals &&
|
||||
- Object.prototype
|
||||
- .hasOwnProperty.call(
|
||||
- locals,
|
||||
- name,
|
||||
- )
|
||||
- ) {
|
||||
- return locals[name];
|
||||
- }
|
||||
-
|
||||
- return peekScope(name);
|
||||
- },
|
||||
- function (name, value) {
|
||||
- if (
|
||||
- locals &&
|
||||
- Object.prototype
|
||||
- .hasOwnProperty.call(
|
||||
- locals,
|
||||
- name,
|
||||
- )
|
||||
- ) {
|
||||
- locals[name] = value;
|
||||
- } else {
|
||||
- writeScope(name, value);
|
||||
- }
|
||||
- },
|
||||
- function (body) {
|
||||
- return runStmt(
|
||||
- body,
|
||||
- locals,
|
||||
- );
|
||||
- },
|
||||
- );
|
||||
+ for (
|
||||
+ var statementIndex = 0;
|
||||
+ statementIndex <
|
||||
+ statements.length;
|
||||
+ statementIndex++
|
||||
+ ) {
|
||||
+ var result = runStatement(
|
||||
+ statements[statementIndex],
|
||||
+ function (expression) {
|
||||
+ return evalExpr(
|
||||
+ expression,
|
||||
+ locals,
|
||||
+ );
|
||||
+ },
|
||||
+ function (name) {
|
||||
+ if (
|
||||
+ locals &&
|
||||
+ Object.prototype
|
||||
+ .hasOwnProperty.call(
|
||||
+ locals,
|
||||
+ name,
|
||||
+ )
|
||||
+ ) {
|
||||
+ return locals[name];
|
||||
+ }
|
||||
+
|
||||
+ return peekScope(name);
|
||||
+ },
|
||||
+ function (name, value) {
|
||||
+ if (
|
||||
+ locals &&
|
||||
+ Object.prototype
|
||||
+ .hasOwnProperty.call(
|
||||
+ locals,
|
||||
+ name,
|
||||
+ )
|
||||
+ ) {
|
||||
+ locals[name] = value;
|
||||
+ } else {
|
||||
+ writeScope(name, value);
|
||||
+ }
|
||||
+ },
|
||||
+ function (body) {
|
||||
+ return runStmt(
|
||||
+ body,
|
||||
+ locals,
|
||||
+ );
|
||||
+ },
|
||||
+ );
|
||||
|
||||
- if (result.returned) {
|
||||
- return result;
|
||||
+ if (result.returned) {
|
||||
+ return result;
|
||||
+ }
|
||||
}
|
||||
- }
|
||||
|
||||
- return {
|
||||
- returned: false,
|
||||
- value: undefined,
|
||||
- };
|
||||
+ return {
|
||||
+ returned: false,
|
||||
+ value: undefined,
|
||||
+ };
|
||||
+ });
|
||||
}
|
||||
|
||||
function installBehaviorFunctions(source) {
|
||||
@@ -42,8 +42,8 @@
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.119",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@iconify-json/lucide": "^1.2.0",
|
||||
"@iconify/tailwind4": "^1.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
@@ -66,15 +66,15 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/authz": {
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/captcha": {
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts",
|
||||
},
|
||||
@@ -109,29 +109,29 @@
|
||||
},
|
||||
"packages/compiler": {
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/csr": {
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
@@ -151,7 +151,7 @@
|
||||
},
|
||||
"packages/dev-toolbar": {
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.2",
|
||||
@@ -159,63 +159,63 @@
|
||||
},
|
||||
"packages/encryption": {
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/helpers": {
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/i18n": {
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/jwt": {
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/mobile": {
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/native": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/native": {
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/oauth": {
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/pubsub": {
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/queue": {
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/reactive": {
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/router": {
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
@@ -223,14 +223,14 @@
|
||||
},
|
||||
"packages/ssr": {
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/styles": {
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
@@ -239,37 +239,37 @@
|
||||
},
|
||||
"packages/syntax": {
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/test": {
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/tracking": {
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/uploader": {
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/validation": {
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
"services/managed-captcha": {
|
||||
"name": "@wrnexus/managed-captcha-service",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/captcha": "workspace:*",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# WRNexusJS 0.4 architecture
|
||||
|
||||
## Goals
|
||||
|
||||
WRNexusJS 0.4 keeps the existing SSR-first model while making advanced systems installable without application-owned copies or manual wiring. The framework remains conservative: HTML is rendered on the server, the reactive runtime loads only for reactive pages, and package browser code loads only when its component or markup declares a runtime requirement.
|
||||
|
||||
## Request and render path
|
||||
|
||||
1. The CLI or development server loads `wrnexus.config.*`.
|
||||
2. `@wrnexus/plugin` discovers explicit plugins and installed package manifests.
|
||||
3. Contributions are normalized and validated for duplicate IDs, paths, routes, and migrations.
|
||||
4. The router combines application and package components/routes/middleware.
|
||||
5. `.wrn` sources compile through plugin AST/code transforms.
|
||||
6. SSR renders page and component HTML.
|
||||
7. Rendered `data-wrnexus-runtime` markers are matched against registered client runtimes.
|
||||
8. Only referenced runtime scripts are added to the response.
|
||||
9. CSR navigation loads newly required runtime chunks, calls `mount`, and calls `unmount` before replacing old page content.
|
||||
|
||||
## Package contribution model
|
||||
|
||||
A package can contribute:
|
||||
|
||||
- component directories
|
||||
- client runtimes
|
||||
- static or generated assets
|
||||
- stylesheet entries and Tailwind scan sources
|
||||
- page, API, and realtime routes
|
||||
- middleware
|
||||
- database migrations
|
||||
- DevToolbar panels
|
||||
- compiler diagnostics and transforms
|
||||
- build and server lifecycle hooks
|
||||
|
||||
Contributions are declared by a package plugin or by the `wrnexus` field in `package.json`.
|
||||
|
||||
## Client runtime rules
|
||||
|
||||
A runtime has a stable ID, source entry, loading policy, module/classic format, and singleton policy. Development serves it from `/__wrnexus/assets/`; TypeScript runtime entries are browser-bundled on demand. Production builds emit content-hashed immutable runtime chunks and store their paths in the static server manifest.
|
||||
|
||||
Runtime code should register:
|
||||
|
||||
```js
|
||||
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
||||
window.__wrnexusRuntimes.example = {
|
||||
mount(root) {},
|
||||
unmount(root) {},
|
||||
};
|
||||
```
|
||||
|
||||
Mount and unmount must be idempotent. Event listeners, observers, audio, timers, and provider widgets must be cleaned up during unmount.
|
||||
|
||||
## Package assets and styles
|
||||
|
||||
Package assets use validated framework paths and receive correct MIME and `nosniff` headers. Production stores package assets in content-addressed files while preserving their declared public URLs; immutable caching remains opt-in. Package component directories and style sources are automatically added to application stylesheet processing, so apps do not need manual Tailwind `@source` entries for installed systems.
|
||||
|
||||
Production stylesheet processing fails closed by default. A Tailwind/PostCSS failure cannot silently ship unprocessed CSS unless the application explicitly selects fallback behavior.
|
||||
|
||||
## Package migrations
|
||||
|
||||
Packages can register inline SQL, a SQL file, or an ordered directory. Migration names are namespaced by package migration ID and may target the default or a named database. Development applies application and package migrations through the same migration table. Production copies both into the build output.
|
||||
|
||||
## Development experience
|
||||
|
||||
The development server watches application and workspace package component/runtime/style sources. Package `.wrn` changes invalidate both compiled modules and the stylesheet cache. `wrnexus inspect` exposes packages, plugins, routes, assets, runtimes, styles, migrations, and build reports. DevToolbar receives a platform snapshot and package panels.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The 0.4 migration removes legacy CAPTCHA script tags, archives manually copied runtime files instead of deleting them, and leaves user-authored application code intact. Existing explicit plugins continue to work and take precedence over automatically discovered plugins with the same name.
|
||||
+20
-20
@@ -6,26 +6,26 @@ The self-hosted and managed engines support 18 concrete renderers plus `random`.
|
||||
|
||||
## Concrete styles
|
||||
|
||||
| Style | Main effect | Suggested use |
|
||||
| --- | --- | --- |
|
||||
| `classic` | Dots, crossing lines, glyph jitter | General default pool |
|
||||
| `collision` | Overlapping coloured glyphs and bars | Medium or hard challenges |
|
||||
| `snow` | Dense snow-like speckles | Medium challenges |
|
||||
| `corrosion` | Eroded glyph patches and rust-like noise | Medium or hard challenges |
|
||||
| `spiderweb` | Connected web lines and nodes | Medium challenges |
|
||||
| `cross-shadow` | Multi-colour offset shadows | Medium challenges |
|
||||
| `split` | Horizontally shifted image bands | Medium or hard challenges |
|
||||
| `split2` | Vertically shifted image strips | Medium or hard challenges |
|
||||
| `cut` | Slashed and interrupted character strokes | Medium challenges |
|
||||
| `darts` | Radial lines and target rings | Medium challenges |
|
||||
| `distortion` | Two-axis sinusoidal distortion | Medium or hard challenges |
|
||||
| `stitch` | Dashed seams and cross stitches | Easy or medium challenges |
|
||||
| `striped` | Diagonal and horizontal stripes | Easy or medium challenges |
|
||||
| `wave` | Strong wave transformation | Medium or hard challenges |
|
||||
| `grid-noise` | Grid lines and translucent cells | Easy or medium challenges |
|
||||
| `scribble` | Random-walk line scribbles | Medium challenges |
|
||||
| `pixel` | Pixel blocks and shifted regions | Medium challenges |
|
||||
| `broken-lines` | Missing stroke segments and fragments | Medium or hard challenges |
|
||||
| Style | Main effect | Suggested use |
|
||||
| -------------- | ----------------------------------------- | ------------------------- |
|
||||
| `classic` | Dots, crossing lines, glyph jitter | General default pool |
|
||||
| `collision` | Overlapping coloured glyphs and bars | Medium or hard challenges |
|
||||
| `snow` | Dense snow-like speckles | Medium challenges |
|
||||
| `corrosion` | Eroded glyph patches and rust-like noise | Medium or hard challenges |
|
||||
| `spiderweb` | Connected web lines and nodes | Medium challenges |
|
||||
| `cross-shadow` | Multi-colour offset shadows | Medium challenges |
|
||||
| `split` | Horizontally shifted image bands | Medium or hard challenges |
|
||||
| `split2` | Vertically shifted image strips | Medium or hard challenges |
|
||||
| `cut` | Slashed and interrupted character strokes | Medium challenges |
|
||||
| `darts` | Radial lines and target rings | Medium challenges |
|
||||
| `distortion` | Two-axis sinusoidal distortion | Medium or hard challenges |
|
||||
| `stitch` | Dashed seams and cross stitches | Easy or medium challenges |
|
||||
| `striped` | Diagonal and horizontal stripes | Easy or medium challenges |
|
||||
| `wave` | Strong wave transformation | Medium or hard challenges |
|
||||
| `grid-noise` | Grid lines and translucent cells | Easy or medium challenges |
|
||||
| `scribble` | Random-walk line scribbles | Medium challenges |
|
||||
| `pixel` | Pixel blocks and shifted regions | Medium challenges |
|
||||
| `broken-lines` | Missing stroke segments and fragments | Medium or hard challenges |
|
||||
|
||||
## Random pool
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Package runtimes and automatic assets
|
||||
|
||||
## User experience
|
||||
|
||||
Installing an advanced package should be enough:
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/captcha
|
||||
```
|
||||
|
||||
Then use its component:
|
||||
|
||||
```wrn
|
||||
<Captcha type="number" action="signup" />
|
||||
```
|
||||
|
||||
The user does not copy JavaScript, add a public asset, add a script tag, or duplicate the component.
|
||||
|
||||
## Declaring a package plugin
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@example/maps",
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./src/plugin.ts",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
|
||||
export default function mapsPlugin() {
|
||||
return definePlugin({
|
||||
name: "@example/maps",
|
||||
componentDirs: [new URL("../components", import.meta.url).pathname],
|
||||
clientRuntimes: [
|
||||
{
|
||||
id: "maps",
|
||||
entry: new URL("../client/maps.ts", import.meta.url).pathname,
|
||||
type: "module",
|
||||
load: "defer",
|
||||
singleton: true,
|
||||
},
|
||||
],
|
||||
styleSources: [
|
||||
{
|
||||
id: "maps-components",
|
||||
source: new URL("../components", import.meta.url).pathname,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The rendered component declares:
|
||||
|
||||
```html
|
||||
<div data-wrnexus-runtime="maps"></div>
|
||||
```
|
||||
|
||||
## Development behavior
|
||||
|
||||
- Runtime URL defaults to `/__wrnexus/assets/maps.js`.
|
||||
- JavaScript files can be served directly.
|
||||
- TypeScript/TSX entries are bundled for the browser.
|
||||
- MIME type is JavaScript and `X-Content-Type-Options: nosniff` is set.
|
||||
- Assets use `no-cache` during development.
|
||||
- Workspace package sources participate in HMR.
|
||||
|
||||
## Production behavior
|
||||
|
||||
- Runtime entries are browser-bundled and minified when required.
|
||||
- The emitted filename includes a SHA-256 content hash.
|
||||
- The asset is served with an immutable cache policy.
|
||||
- The final HTML includes the runtime only when its marker appears.
|
||||
- Multiple instances inject one script.
|
||||
- Build reports list plugins, runtime chunks, assets, component directories, routes, and migrations.
|
||||
|
||||
## CSR navigation
|
||||
|
||||
Before replacing `#app`, WRNexusJS calls package `unmount` hooks for runtimes used by the current content. It imports script attributes from the fetched document, loads missing chunks once, then calls `mount` for the new page.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- Runtime and asset IDs are validated.
|
||||
- Duplicate IDs and public paths fail startup/build.
|
||||
- Local package asset paths cannot contain traversal segments.
|
||||
- Production builds do not rely on application public-directory copies.
|
||||
- Secrets must remain server-side; browser runtimes receive only public configuration and one-use response tokens.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Existing package upgrades in 0.4
|
||||
|
||||
All 30 packages are aligned to version 0.4.0.
|
||||
|
||||
| Package | Added or strengthened capability |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `@wrnexus/syntax` | syntax version/features, source ranges, diagnostic summaries |
|
||||
| `@wrnexus/compiler` | compilation cache and dependency graph |
|
||||
| `@wrnexus/reactive` | watch, async resources, cleanup scopes |
|
||||
| `@wrnexus/core` | service container, lifecycle, health checks, Problem Details, request IDs, idempotency |
|
||||
| `@wrnexus/router` | named routes, manifests, reverse URL generation |
|
||||
| `@wrnexus/ssr` | structured/deduplicated scripts and streaming document output |
|
||||
| `@wrnexus/csr` | package runtime loading plus mount/unmount lifecycle during navigation |
|
||||
| `@wrnexus/plugin` | automatic discovery and package components/runtimes/assets/styles/routes/middleware/migrations |
|
||||
| `@wrnexus/dev-server` | virtual package assets, TypeScript runtime bundling, package HMR, migration integration |
|
||||
| `@wrnexus/cli` | package-aware builds, inspect commands, system generator, 0.4 migration |
|
||||
| `@wrnexus/dev-toolbar` | package panels and platform snapshots |
|
||||
| `@wrnexus/styles` | package scan sources/entries, token and contrast audits, fail-closed production processing |
|
||||
| `@wrnexus/test` | request/context helpers, JSON/problem assertions, deferred/wait utilities, cookie jar |
|
||||
| `@wrnexus/db` | ordered migration lists, cursor pagination, optimistic updates, tenant/soft-delete helpers |
|
||||
| `@wrnexus/queue` | pluggable durable store, leases, priorities, retries, dead letters |
|
||||
| `@wrnexus/pubsub` | resilient delivery wrapper and expiring presence channels |
|
||||
| `@wrnexus/uploader` | filename isolation, content inspection, checksums, policies, signed file tokens |
|
||||
| `@wrnexus/encryption` | versioned encrypted payloads and rotatable keyrings |
|
||||
| `@wrnexus/jwt` | key-ID signing, verification keyrings, safe decoding |
|
||||
| `@wrnexus/oauth` | state/PKCE storage, refresh, OIDC discovery, safe return URLs |
|
||||
| `@wrnexus/authz` | explainable authorization decisions, owner policies, compositions, filtering |
|
||||
| `@wrnexus/captcha` | package-managed component/runtime, advanced challenges, page gates, audio, image styles |
|
||||
| `@wrnexus/validation` | async cross-field refinements, async request parsing, OpenAPI conversion |
|
||||
| `@wrnexus/i18n` | fallback chains, coverage reporting, locale formatters, plural message formatting |
|
||||
| `@wrnexus/ui` | component metadata access and reference audits |
|
||||
| `@wrnexus/helpers` | retry/backoff, abortable sleep, timeout, stable JSON, safe parsing |
|
||||
| `@wrnexus/tracking` | sampled/batched telemetry pipeline and sink abstraction |
|
||||
| `@wrnexus/ai` | provider-neutral client, fallback providers, usage metadata |
|
||||
| `@wrnexus/mobile` | deep links, offline task queue, environment detection |
|
||||
| `@wrnexus/native` | capability manifests, target inspection, permission manager |
|
||||
|
||||
These additions preserve existing public APIs. New advanced modules are exported from each package root.
|
||||
@@ -0,0 +1,57 @@
|
||||
# WRNexusJS 0.4 test checklist
|
||||
|
||||
## Automated
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run verify:0.4
|
||||
bun run validate:0.4
|
||||
```
|
||||
|
||||
`validate:0.4` performs:
|
||||
|
||||
1. structural release verification;
|
||||
2. TypeScript checking;
|
||||
3. ESLint;
|
||||
4. all package tests;
|
||||
5. Prettier verification;
|
||||
6. 0.4 integration tests;
|
||||
7. basic application tests and production build;
|
||||
8. component showcase generation, tests, and build;
|
||||
9. CAPTCHA showcase tests and build;
|
||||
10. managed CAPTCHA service tests.
|
||||
|
||||
## Package runtime
|
||||
|
||||
- Package absent and marker absent: no runtime script.
|
||||
- Package installed but component unused: no runtime script.
|
||||
- One or many components used: one runtime script.
|
||||
- Script has correct MIME and `nosniff` headers.
|
||||
- TypeScript runtime entries compile in development.
|
||||
- Production runtime filename is content hashed.
|
||||
- CSR navigation to a runtime page loads and mounts it.
|
||||
- Navigation away calls unmount and removes listeners/timers.
|
||||
- Runtime and asset public-path collisions fail clearly.
|
||||
|
||||
## CAPTCHA
|
||||
|
||||
- No application public copy of `captcha.js` exists.
|
||||
- No manual script tag exists.
|
||||
- Number, alphabet, alphanumeric, calculation, audio, image, and not-robot modes work.
|
||||
- Compact, normal, and big layouts work at mobile and desktop widths.
|
||||
- Listen can be hidden or shown.
|
||||
- Disturbance values 25–75 affect image difficulty.
|
||||
- All image styles and random pools render.
|
||||
- Protected page grants and redirects after verification.
|
||||
- Validated form rejects invalid fields and unverified CAPTCHA.
|
||||
- Tokens expire, are single use, and are action/host bound.
|
||||
|
||||
## Migration
|
||||
|
||||
- Updating an old app creates a backup.
|
||||
- Legacy CAPTCHA script tags are removed once.
|
||||
- Legacy runtime files are archived, not silently lost.
|
||||
- Re-running the migration is idempotent.
|
||||
- Existing deployed apps continue to build.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Upgrade to WRNexusJS 0.4.0
|
||||
|
||||
## Recommended upgrade
|
||||
|
||||
```bash
|
||||
bun add -D @wrnexus/cli@0.4.0
|
||||
bun x wrnexus update . --version=0.4.0
|
||||
```
|
||||
|
||||
For this monorepo source bundle:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run verify:0.4
|
||||
bun run validate:0.4
|
||||
```
|
||||
|
||||
## CAPTCHA migration
|
||||
|
||||
Before 0.4, CAPTCHA integrations could contain copied files such as:
|
||||
|
||||
- `public/assets/wrnexus/captcha.js`
|
||||
- `public/__wrnexus/captcha.js`
|
||||
- a manually copied `Captcha.wrn`
|
||||
- an application-authored `<script src="...captcha.js">`
|
||||
|
||||
The 0.4 updater:
|
||||
|
||||
1. removes known legacy CAPTCHA script tags from `.wrn` files;
|
||||
2. archives public runtime copies under `.wrnexus/legacy-assets/0.4.0/`;
|
||||
3. preserves customized files in the backup created before migration;
|
||||
4. records the migration at `.wrnexus/migrations/0.4.0.json`.
|
||||
|
||||
After updating, keep `@wrnexus/captcha` in application dependencies and use `<Captcha />`. Its component and browser runtime are discovered automatically.
|
||||
|
||||
## Package authors
|
||||
|
||||
Add a `wrnexus.plugin` package manifest or manifest contributions. Ensure IDs are stable and globally unique. Client runtime code must expose idempotent `mount` and `unmount` functions. Use package migrations for schema required by the package instead of asking applications to copy migration files.
|
||||
|
||||
## Compatibility checks
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
wrnexus doctor .
|
||||
wrnexus inspect packages .
|
||||
wrnexus inspect plugins .
|
||||
wrnexus inspect runtimes .
|
||||
wrnexus inspect assets .
|
||||
wrnexus inspect routes .
|
||||
wrnexus inspect migrations .
|
||||
```
|
||||
|
||||
Production style processing now fails the build when the configured processor fails. Install the application’s Tailwind/PostCSS dependencies before building.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wrnexus",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wrnexus",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
|
||||
@@ -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.3.4",
|
||||
"version": "0.4.0",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Db, ExecResult } from "@wrnexus/db";
|
||||
import { users } from "./schema.ts";
|
||||
|
||||
export async function GetUserByEmail(db: Db, args: { email: string }): Promise<{ id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null> {
|
||||
return (await db.one("SELECT * FROM users WHERE email = ?", [args.email], users)) as { id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null;
|
||||
return (await db.one("SELECT * FROM users WHERE email = $1", [args.email], users)) as { id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null;
|
||||
}
|
||||
|
||||
export async function ListUsers(db: Db): Promise<{ id: number; name: string; active: boolean }[]> {
|
||||
@@ -11,13 +11,13 @@ export async function ListUsers(db: Db): Promise<{ id: number; name: string; act
|
||||
}
|
||||
|
||||
export async function CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null> {
|
||||
return (await db.one("SELECT COUNT(*) AS n FROM users WHERE active = ?", [args.active])) as { n: number } | null;
|
||||
return (await db.one("SELECT COUNT(*) AS n FROM users WHERE active = $1", [args.active])) as { n: number } | null;
|
||||
}
|
||||
|
||||
export async function CreateUser(db: Db, args: { email: string; name: string; active: boolean }): Promise<ExecResult> {
|
||||
return db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [args.email, args.name, args.active]);
|
||||
return db.exec("INSERT INTO users (email, name, active) VALUES ($1, $2, $3)", [args.email, args.name, args.active]);
|
||||
}
|
||||
|
||||
export async function DeactivateUser(db: Db, args: { id: number }): Promise<ExecResult> {
|
||||
return db.exec("UPDATE users SET active = 0 WHERE id = ?", [args.id]);
|
||||
return db.exec("UPDATE users SET active = 0 WHERE id = $1", [args.id]);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ function safeReturnPath(value: string | null, baseUrl: URL): string {
|
||||
|
||||
export async function POST(ctx: Context): Promise<Response> {
|
||||
const contentType = ctx.req.headers.get("content-type") ?? "";
|
||||
let submittedReturnTo: string | null = null;
|
||||
let submittedReturnTo: string | null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
component Captcha {
|
||||
props {
|
||||
provider = "self-hosted"
|
||||
siteKey = ""
|
||||
type = "alphanumeric"
|
||||
action = "form-submit"
|
||||
presentation = "visual"
|
||||
difficulty = "normal"
|
||||
disturbance = 50
|
||||
imageStyle = "random"
|
||||
allowedStyles = ""
|
||||
excludedStyles = ""
|
||||
randomizeStyle = false
|
||||
locale = "en"
|
||||
size = "normal"
|
||||
color = "primary"
|
||||
class = ""
|
||||
|
||||
id = ""
|
||||
name = "wrn-captcha-response"
|
||||
endpoint = "/__wrnexus/captcha/challenge"
|
||||
verifyEndpoint = "/__wrnexus/captcha/verify"
|
||||
responseField = "wrn-captcha-response"
|
||||
|
||||
label = "Security verification"
|
||||
description = "Complete the challenge to continue."
|
||||
helpText = ""
|
||||
requiredMessage = "Please complete the security check."
|
||||
incorrectMessage = "That answer was not correct. Try again."
|
||||
expiredMessage = "This challenge expired. Load a new one."
|
||||
networkMessage = "The verification service is unavailable. Try again."
|
||||
|
||||
autoLoad = true
|
||||
autoVerify = false
|
||||
showVerify = true
|
||||
showRefresh = true
|
||||
showAudio = true
|
||||
showListen = true
|
||||
showStatus = true
|
||||
disabled = false
|
||||
required = true
|
||||
compact = false
|
||||
|
||||
@event ready = function
|
||||
@event challenge = function
|
||||
@event input = function
|
||||
@event verify = function
|
||||
@event success = function
|
||||
@event failure = function
|
||||
@event expired = function
|
||||
@event refresh = function
|
||||
@event audioStart = function
|
||||
@event audioEnd = function
|
||||
@event error = function
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
id='{id}'
|
||||
data-wrn-captcha
|
||||
data-captcha-provider='{provider}'
|
||||
data-captcha-site-key='{siteKey}'
|
||||
data-captcha-type='{type}'
|
||||
data-captcha-action='{action}'
|
||||
data-captcha-presentation='{presentation}'
|
||||
data-captcha-difficulty='{difficulty}'
|
||||
data-captcha-disturbance='{disturbance}'
|
||||
data-captcha-image-style='{imageStyle}'
|
||||
data-captcha-allowed-styles='{allowedStyles}'
|
||||
data-captcha-excluded-styles='{excludedStyles}'
|
||||
data-captcha-randomize-style='{randomizeStyle}'
|
||||
data-captcha-locale='{locale}'
|
||||
data-captcha-size='{size}'
|
||||
data-captcha-color='{color}'
|
||||
data-captcha-endpoint='{endpoint}'
|
||||
data-captcha-verify-endpoint='{verifyEndpoint}'
|
||||
data-captcha-response-field='{responseField}'
|
||||
data-captcha-name='{name}'
|
||||
data-captcha-auto-load='{autoLoad}'
|
||||
data-captcha-auto-verify='{autoVerify}'
|
||||
data-captcha-show-verify='{showVerify}'
|
||||
data-captcha-show-refresh='{showRefresh}'
|
||||
data-captcha-show-audio='{showAudio}'
|
||||
data-captcha-show-listen='{showListen}'
|
||||
data-captcha-show-status='{showStatus}'
|
||||
data-captcha-disabled='{disabled}'
|
||||
data-captcha-required='{required}'
|
||||
data-captcha-compact='{compact}'
|
||||
data-captcha-required-message='{requiredMessage}'
|
||||
data-captcha-incorrect-message='{incorrectMessage}'
|
||||
data-captcha-expired-message='{expiredMessage}'
|
||||
data-captcha-network-message='{networkMessage}'
|
||||
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}'
|
||||
>
|
||||
<script
|
||||
src="/assets/wrnexus/captcha.js"
|
||||
defer
|
||||
data-wrn-captcha-runtime
|
||||
></script>
|
||||
|
||||
<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'>
|
||||
<div class='min-w-0'>
|
||||
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--shield-check] size-5 shrink-0 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-4'
|
||||
></span>
|
||||
<h3 class='m-0 text-sm font-semibold leading-5 text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
|
||||
{label}
|
||||
</h3>
|
||||
</div>
|
||||
<p class='m-0 mt-1 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-size=big]/captcha:text-sm'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
data-captcha-verified-badge
|
||||
hidden
|
||||
class='shrink-0 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-0.5 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:py-0.5 group-data-[captcha-size=compact]/captcha:text-[10px]'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-check] size-3.5 group-data-[captcha-size=compact]/captcha:size-3'></span>
|
||||
Verified
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div
|
||||
data-captcha-provider-mount
|
||||
hidden
|
||||
class='flex min-h-16 w-full items-center justify-center overflow-hidden rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-2 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=compact]/captcha:p-1.5'
|
||||
></div>
|
||||
|
||||
<div
|
||||
data-captcha-loading
|
||||
hidden
|
||||
class='flex min-h-28 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] text-sm text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:min-h-14 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:min-h-36 group-data-[captcha-type=not-robot]/captcha:m-3 group-data-[captcha-type=not-robot]/captcha:min-h-16'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--loader-circle] size-5 animate-spin group-data-[captcha-size=compact]/captcha:size-4'></span>
|
||||
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-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>
|
||||
</div>
|
||||
|
||||
<div data-captcha-not-robot hidden class='w-full'>
|
||||
<button
|
||||
data-captcha-not-robot-button
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
class='flex min-h-20 w-full items-center gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-4 py-3 text-left transition-[border-color,box-shadow,background-color] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-wait disabled:opacity-80 group-data-[captcha-size=compact]/captcha:min-h-16 group-data-[captcha-size=compact]/captcha:gap-2.5 group-data-[captcha-size=compact]/captcha:px-3 group-data-[captcha-size=compact]/captcha:py-2.5 group-data-[captcha-size=big]/captcha:min-h-24 group-data-[captcha-size=big]/captcha:px-5 group-data-[captcha-size=big]/captcha:py-4'
|
||||
>
|
||||
<span
|
||||
data-captcha-not-robot-control
|
||||
aria-hidden="true"
|
||||
class='inline-flex size-7 shrink-0 items-center justify-center rounded-md border-2 border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] text-white transition-colors data-[verified=true]:border-[var(--wire-color-success)] data-[verified=true]:bg-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:size-6 group-data-[captcha-size=big]/captcha:size-8'
|
||||
>
|
||||
<span data-captcha-not-robot-empty class='size-full'></span>
|
||||
<span data-captcha-not-robot-spinner hidden class='icon-[lucide--loader-circle] size-4 animate-spin text-[var(--captcha-accent)]'></span>
|
||||
<span data-captcha-not-robot-check hidden class='icon-[lucide--check] size-5'></span>
|
||||
</span>
|
||||
|
||||
<span data-captcha-not-robot-label class='min-w-0 flex-1 text-sm font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
|
||||
I'm not a robot
|
||||
</span>
|
||||
|
||||
<span class='flex shrink-0 flex-col items-center gap-0.5 text-[10px] leading-none text-[var(--wire-color-muted)]'>
|
||||
<span aria-hidden="true" class='icon-[lucide--shield-check] size-6 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-5 group-data-[captcha-size=big]/captcha:size-7'></span>
|
||||
<span>WRNexus</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-captcha-image-wrap
|
||||
hidden
|
||||
class='overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white'
|
||||
>
|
||||
<img
|
||||
data-captcha-image
|
||||
src=""
|
||||
alt="CAPTCHA challenge"
|
||||
draggable="false"
|
||||
class='block h-auto min-h-20 w-full select-none object-contain group-data-[captcha-size=compact]/captcha:max-h-24 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=big]/captcha:min-h-28'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-captcha-items
|
||||
hidden
|
||||
role="group"
|
||||
class='grid grid-cols-2 gap-2 group-data-[captcha-size=compact]/captcha:gap-1 sm:grid-cols-3 group-data-[captcha-size=big]/captcha:gap-3'
|
||||
></div>
|
||||
|
||||
<template data-captcha-item-template>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
class='group relative overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white p-1 group-data-[captcha-size=compact]/captcha:p-0.5 transition-[border-color,box-shadow,transform] duration-[var(--wire-motion-fast)] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] aria-[pressed=true]:border-[var(--captcha-accent)] aria-[pressed=true]:ring-2 aria-[pressed=true]:ring-[var(--captcha-accent)]'
|
||||
>
|
||||
<img
|
||||
data-captcha-item-image
|
||||
src=""
|
||||
alt=""
|
||||
draggable="false"
|
||||
class='aspect-[4/3] w-full select-none object-cover'
|
||||
/>
|
||||
<span
|
||||
data-captcha-item-check
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
class='absolute right-1.5 top-1.5 inline-flex size-5 group-data-[captcha-size=compact]/captcha:right-1 group-data-[captcha-size=compact]/captcha:top-1 group-data-[captcha-size=compact]/captcha:size-4 items-center justify-center rounded-full bg-[var(--captcha-accent)] text-white shadow-sm'
|
||||
>
|
||||
<span class='icon-[lucide--check] size-3.5'></span>
|
||||
</span>
|
||||
</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"
|
||||
name=""
|
||||
value=""
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
class='pointer-events-none absolute -left-[10000px] top-auto h-px w-px overflow-hidden opacity-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div data-captcha-error hidden role="alert" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)] 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:mx-3 group-data-[captcha-type=not-robot]/captcha:mb-3'>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-alert] mt-0.5 size-3.5 shrink-0'></span>
|
||||
<span data-captcha-error-message></span>
|
||||
</div>
|
||||
|
||||
<p data-captcha-help class='m-0 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
{helpText}
|
||||
</p>
|
||||
|
||||
<footer data-captcha-footer class='flex flex-wrap items-center justify-between gap-2 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<div class='flex flex-wrap items-center gap-1.5 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-0.5'>
|
||||
<button
|
||||
data-captcha-audio
|
||||
hidden
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--volume-2] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
Listen
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-captcha-audio-alternative
|
||||
hidden
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--ear] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
Use audio
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-captcha-refresh
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--refresh-cw] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
New challenge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
data-captcha-verify
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] bg-[var(--captcha-accent)] px-3 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-2.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-white shadow-sm transition-[filter,transform] hover:brightness-95 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span data-captcha-verify-icon aria-hidden="true" class='icon-[lucide--shield-check] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
<span data-captcha-verify-spinner hidden aria-hidden="true" class='icon-[lucide--loader-circle] size-4 animate-spin group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
<span data-captcha-verify-label>Verify</span>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<input
|
||||
data-captcha-response
|
||||
type="hidden"
|
||||
name='{responseField}'
|
||||
value=""
|
||||
/>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createCaptchaEngine, createCaptchaHttpHandlers, MemoryCaptchaStore } from "@wrnexus/captcha/server";
|
||||
import {
|
||||
createCaptchaEngine,
|
||||
createCaptchaHttpHandlers,
|
||||
MemoryCaptchaStore,
|
||||
} from "@wrnexus/captcha/server";
|
||||
|
||||
export const captchaEngine = createCaptchaEngine({
|
||||
secret: process.env.CAPTCHA_SECRET ?? "development-only-change-this-captcha-secret-123456",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@iconify/tailwind4";
|
||||
@source "../**/*.wrn";
|
||||
@source "../../../packages/captcha/components/*.wrn";
|
||||
|
||||
html { color-scheme: light dark; }
|
||||
body { margin: 0; background: var(--wire-color-bg); color: var(--wire-color-text); font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif; }
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--wire-color-bg);
|
||||
color: var(--wire-color-text);
|
||||
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,878 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var RUNTIME_KEY = "__wrnexusCaptchaRuntime";
|
||||
var existingRuntime = window[RUNTIME_KEY];
|
||||
|
||||
if (existingRuntime && typeof existingRuntime.scan === "function") {
|
||||
existingRuntime.scan(document);
|
||||
return;
|
||||
}
|
||||
|
||||
var states = new WeakMap();
|
||||
var scriptPromises = new Map();
|
||||
var instanceCounter = 0;
|
||||
|
||||
function bool(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function numberInRange(value, fallback, minimum, maximum) {
|
||||
var parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function normalizeSize(value, compact) {
|
||||
if (compact) return "compact";
|
||||
var normalized = String(value || "normal").trim().toLowerCase();
|
||||
if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
|
||||
if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function commaList(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map(function (item) { return item.trim().toLowerCase(); })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function text(root, selector, value) {
|
||||
var element = root.querySelector(selector);
|
||||
if (element) element.textContent = value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function show(element, visible) {
|
||||
if (!element) return;
|
||||
element.hidden = !visible;
|
||||
}
|
||||
|
||||
function setBusy(root, busy) {
|
||||
root.setAttribute("aria-busy", busy ? "true" : "false");
|
||||
}
|
||||
|
||||
function config(root) {
|
||||
var data = root.dataset;
|
||||
var responseField = data.captchaResponseField || data.captchaName || "wrn-captcha-response";
|
||||
var compact = bool(data.captchaCompact, false);
|
||||
var size = normalizeSize(data.captchaSize, compact);
|
||||
|
||||
return {
|
||||
provider: data.captchaProvider || "self-hosted",
|
||||
siteKey: data.captchaSiteKey || "",
|
||||
type: data.captchaType || "alphanumeric",
|
||||
action: data.captchaAction || "form-submit",
|
||||
presentation: data.captchaPresentation || "visual",
|
||||
difficulty: data.captchaDifficulty || "normal",
|
||||
disturbance: numberInRange(data.captchaDisturbance, 50, 25, 75),
|
||||
imageStyle: data.captchaImageStyle || "random",
|
||||
allowedStyles: commaList(data.captchaAllowedStyles),
|
||||
excludedStyles: commaList(data.captchaExcludedStyles),
|
||||
randomizeStyle: bool(data.captchaRandomizeStyle, false),
|
||||
locale: data.captchaLocale || "en",
|
||||
size: size,
|
||||
endpoint: data.captchaEndpoint || "/__wrnexus/captcha/challenge",
|
||||
verifyEndpoint: data.captchaVerifyEndpoint || "/__wrnexus/captcha/verify",
|
||||
responseField: responseField,
|
||||
autoLoad: bool(data.captchaAutoLoad, true),
|
||||
autoVerify: bool(data.captchaAutoVerify, false),
|
||||
showVerify: bool(data.captchaShowVerify, true),
|
||||
showRefresh: bool(data.captchaShowRefresh, true),
|
||||
showAudio: bool(data.captchaShowAudio, true),
|
||||
showListen: bool(data.captchaShowListen, true),
|
||||
showStatus: bool(data.captchaShowStatus, true),
|
||||
disabled: bool(data.captchaDisabled, false),
|
||||
required: bool(data.captchaRequired, true),
|
||||
compact: compact,
|
||||
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
|
||||
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
|
||||
expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
|
||||
networkMessage: data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
|
||||
};
|
||||
}
|
||||
|
||||
function eventDetail(state, extra) {
|
||||
var challenge = state.challenge;
|
||||
return {
|
||||
component: "Captcha",
|
||||
provider: state.config.provider,
|
||||
type: challenge && challenge.type ? challenge.type : state.config.type,
|
||||
action: state.config.action,
|
||||
disturbance: state.config.disturbance,
|
||||
imageStyle: challenge && challenge.metadata && challenge.metadata.imageStyle
|
||||
? challenge.metadata.imageStyle
|
||||
: state.config.imageStyle,
|
||||
requestedImageStyle: challenge && challenge.metadata && challenge.metadata.requestedImageStyle
|
||||
? challenge.metadata.requestedImageStyle
|
||||
: state.config.imageStyle,
|
||||
imageStylePool: challenge && challenge.metadata && challenge.metadata.imageStylePool
|
||||
? challenge.metadata.imageStylePool
|
||||
: state.config.allowedStyles,
|
||||
size: state.config.size,
|
||||
status: state.status,
|
||||
challengeId: challenge && challenge.id ? challenge.id : "",
|
||||
responseToken: state.responseToken,
|
||||
expiresAt: challenge && challenge.expiresAt ? challenge.expiresAt : null,
|
||||
extra: extra || null,
|
||||
};
|
||||
}
|
||||
|
||||
function emit(state, name, extra) {
|
||||
state.root.dispatchEvent(
|
||||
new CustomEvent(name, {
|
||||
bubbles: true,
|
||||
detail: eventDetail(state, extra),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setResponseToken(state, token) {
|
||||
state.responseToken = token || "";
|
||||
var input = state.root.querySelector("[data-captcha-response]");
|
||||
if (input) {
|
||||
input.name = state.config.responseField;
|
||||
input.value = state.responseToken;
|
||||
}
|
||||
}
|
||||
|
||||
function isNotRobot(state) {
|
||||
return Boolean(
|
||||
(state.challenge && state.challenge.type === "not-robot") ||
|
||||
(!state.challenge && state.config.type === "not-robot"),
|
||||
);
|
||||
}
|
||||
|
||||
function updateNotRobotState(state) {
|
||||
var panel = state.root.querySelector("[data-captcha-not-robot]");
|
||||
var button = state.root.querySelector("[data-captcha-not-robot-button]");
|
||||
var empty = state.root.querySelector("[data-captcha-not-robot-empty]");
|
||||
var spinner = state.root.querySelector("[data-captcha-not-robot-spinner]");
|
||||
var check = state.root.querySelector("[data-captcha-not-robot-check]");
|
||||
var control = state.root.querySelector("[data-captcha-not-robot-control]");
|
||||
var label = state.root.querySelector("[data-captcha-not-robot-label]");
|
||||
var active = isNotRobot(state);
|
||||
var pending = state.notRobotPending || state.status === "verifying";
|
||||
var verified = state.status === "verified";
|
||||
|
||||
show(panel, active && state.status !== "loading" && state.status !== "idle");
|
||||
if (!active) return;
|
||||
|
||||
if (button) {
|
||||
button.disabled = state.config.disabled || pending || verified || state.status === "expired";
|
||||
button.setAttribute("aria-pressed", verified ? "true" : "false");
|
||||
}
|
||||
show(empty, !pending && !verified);
|
||||
show(spinner, pending && !verified);
|
||||
show(check, verified);
|
||||
|
||||
if (control) control.dataset.verified = verified ? "true" : "false";
|
||||
if (label) {
|
||||
label.textContent = verified ? "Verified" : pending ? "Checking…" : "I'm not a robot";
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(state, status, message) {
|
||||
state.status = status;
|
||||
state.root.dataset.captchaStatus = status;
|
||||
setBusy(state.root, status === "loading" || status === "verifying");
|
||||
|
||||
var loading = state.root.querySelector("[data-captcha-loading]");
|
||||
var challenge = state.root.querySelector("[data-captcha-challenge]");
|
||||
var success = state.root.querySelector("[data-captcha-success]");
|
||||
var error = state.root.querySelector("[data-captcha-error]");
|
||||
var badge = state.root.querySelector("[data-captcha-verified-badge]");
|
||||
var verifyButton = state.root.querySelector("[data-captcha-verify]");
|
||||
var verifyIcon = state.root.querySelector("[data-captcha-verify-icon]");
|
||||
var verifySpinner = state.root.querySelector("[data-captcha-verify-spinner]");
|
||||
var verifyLabel = state.root.querySelector("[data-captcha-verify-label]");
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
|
||||
show(loading, status === "loading");
|
||||
show(challenge, status !== "loading" && status !== "idle" && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha");
|
||||
show(badge, status === "verified");
|
||||
|
||||
if (state.config.showStatus) {
|
||||
show(success, status === "verified");
|
||||
show(error, Boolean(message) && status !== "verified");
|
||||
} else {
|
||||
show(success, false);
|
||||
show(error, false);
|
||||
}
|
||||
|
||||
if (message && status === "verified") text(state.root, "[data-captcha-success-message]", message);
|
||||
if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
|
||||
|
||||
if (answer) {
|
||||
answer.disabled = state.config.disabled || status === "verified" || status === "expired" || status === "loading" || status === "verifying";
|
||||
answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
|
||||
}
|
||||
|
||||
if (verifyButton) {
|
||||
verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
|
||||
}
|
||||
|
||||
show(verifyIcon, status !== "verifying");
|
||||
show(verifySpinner, status === "verifying");
|
||||
if (verifyLabel) verifyLabel.textContent = status === "verifying" ? "Verifying…" : "Verify";
|
||||
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
}
|
||||
|
||||
function clearTimer(state) {
|
||||
if (state.timer) {
|
||||
window.clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCountdown(state) {
|
||||
var countdown = state.root.querySelector("[data-captcha-countdown]");
|
||||
if (!countdown || !state.challenge || !state.challenge.expiresAt) {
|
||||
show(countdown, false);
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = Math.max(0, Math.ceil((Number(state.challenge.expiresAt) - Date.now()) / 1000));
|
||||
countdown.textContent = remaining + "s";
|
||||
show(countdown, remaining > 0 && state.status !== "verified");
|
||||
|
||||
if (remaining <= 0 && state.status !== "verified" && state.status !== "expired") {
|
||||
clearTimer(state);
|
||||
setResponseToken(state, "");
|
||||
setStatus(state, "expired", state.config.expiredMessage);
|
||||
emit(state, "expired");
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer(state) {
|
||||
clearTimer(state);
|
||||
if (!state.challenge || !state.challenge.expiresAt) return;
|
||||
updateCountdown(state);
|
||||
state.timer = window.setInterval(function () {
|
||||
updateCountdown(state);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function resetUi(state) {
|
||||
if (state.audioPlayer) {
|
||||
state.audioPlayer.pause();
|
||||
state.audioPlayer.removeAttribute("src");
|
||||
state.audioPlayer.load();
|
||||
state.audioPlayer = null;
|
||||
}
|
||||
if (state.notRobotTimer) {
|
||||
window.clearTimeout(state.notRobotTimer);
|
||||
state.notRobotTimer = null;
|
||||
}
|
||||
|
||||
state.notRobotPending = false;
|
||||
state.challengeLoadedAt = 0;
|
||||
state.answer = "";
|
||||
state.selections = [];
|
||||
state.challenge = null;
|
||||
setResponseToken(state, "");
|
||||
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
var items = state.root.querySelector("[data-captcha-items]");
|
||||
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
|
||||
var providerMount = state.root.querySelector("[data-captcha-provider-mount]");
|
||||
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
|
||||
var notRobot = state.root.querySelector("[data-captcha-not-robot]");
|
||||
|
||||
if (answer) answer.value = "";
|
||||
if (items) items.replaceChildren();
|
||||
show(items, false);
|
||||
show(imageWrap, false);
|
||||
show(notRobot, false);
|
||||
if (honeypot) {
|
||||
honeypot.value = "";
|
||||
honeypot.name = "";
|
||||
}
|
||||
if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
|
||||
providerMount.replaceChildren();
|
||||
show(providerMount, false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateControls(state) {
|
||||
var challenge = state.challenge;
|
||||
var audio = state.root.querySelector("[data-captcha-audio]");
|
||||
var audioAlternative = state.root.querySelector("[data-captcha-audio-alternative]");
|
||||
var refresh = state.root.querySelector("[data-captcha-refresh]");
|
||||
var verify = state.root.querySelector("[data-captcha-verify]");
|
||||
var footer = state.root.querySelector("[data-captcha-footer]");
|
||||
var isExternal = state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha";
|
||||
var notRobot = isNotRobot(state);
|
||||
var locked = state.config.disabled || state.status === "loading" || state.status === "verifying" || state.notRobotPending;
|
||||
|
||||
show(footer, !notRobot);
|
||||
show(audio, !notRobot && state.config.showAudio && state.config.showListen && Boolean(challenge && challenge.audioUrl) && state.status !== "verified");
|
||||
show(audioAlternative, !notRobot && state.config.showAudio && Boolean(challenge && challenge.type === "image") && state.status !== "verified");
|
||||
show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
|
||||
show(verify, !notRobot && state.config.showVerify && !isExternal);
|
||||
|
||||
if (audio) audio.disabled = locked;
|
||||
if (audioAlternative) audioAlternative.disabled = locked;
|
||||
if (refresh) refresh.disabled = locked;
|
||||
if (verify) verify.disabled = locked || state.status === "verified" || state.status === "expired";
|
||||
}
|
||||
|
||||
function renderItems(state, challenge) {
|
||||
var container = state.root.querySelector("[data-captcha-items]");
|
||||
var template = state.root.querySelector("[data-captcha-item-template]");
|
||||
if (!container || !template || !Array.isArray(challenge.items) || challenge.items.length === 0) {
|
||||
show(container, false);
|
||||
return;
|
||||
}
|
||||
|
||||
container.replaceChildren();
|
||||
container.setAttribute("aria-label", challenge.prompt || "Select matching images");
|
||||
|
||||
challenge.items.forEach(function (item, index) {
|
||||
var fragment = template.content.cloneNode(true);
|
||||
var button = fragment.querySelector("button");
|
||||
var image = fragment.querySelector("[data-captcha-item-image]");
|
||||
if (!button || !image) return;
|
||||
|
||||
button.dataset.captchaItemId = item.id;
|
||||
button.setAttribute("aria-label", item.alt || "Challenge tile " + (index + 1));
|
||||
image.src = item.image;
|
||||
image.alt = "";
|
||||
button.addEventListener("click", function () {
|
||||
toggleItem(state, item.id, button);
|
||||
});
|
||||
container.appendChild(fragment);
|
||||
});
|
||||
|
||||
show(container, true);
|
||||
}
|
||||
|
||||
function toggleItem(state, itemId, button) {
|
||||
if (state.config.disabled || state.status !== "ready") return;
|
||||
|
||||
var index = state.selections.indexOf(itemId);
|
||||
var selected = index >= 0;
|
||||
|
||||
if (selected) {
|
||||
state.selections.splice(index, 1);
|
||||
} else {
|
||||
var maximum = Number(state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0);
|
||||
if (maximum > 0 && state.selections.length >= maximum) {
|
||||
var removed = state.selections.shift();
|
||||
var previousButton = state.root.querySelector('[data-captcha-item-id="' + CSS.escape(removed) + '"]');
|
||||
if (previousButton) {
|
||||
previousButton.setAttribute("aria-pressed", "false");
|
||||
show(previousButton.querySelector("[data-captcha-item-check]"), false);
|
||||
}
|
||||
}
|
||||
state.selections.push(itemId);
|
||||
}
|
||||
|
||||
var nowSelected = state.selections.indexOf(itemId) >= 0;
|
||||
button.setAttribute("aria-pressed", nowSelected ? "true" : "false");
|
||||
show(button.querySelector("[data-captcha-item-check]"), nowSelected);
|
||||
emit(state, "input", { selections: state.selections.slice() });
|
||||
|
||||
var minimum = Number(state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0);
|
||||
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
|
||||
}
|
||||
|
||||
function renderChallenge(state, challenge) {
|
||||
state.challenge = challenge;
|
||||
state.challengeLoadedAt = Date.now();
|
||||
state.notRobotPending = false;
|
||||
state.answer = "";
|
||||
state.selections = [];
|
||||
|
||||
var notRobot = challenge.type === "not-robot";
|
||||
state.root.dataset.captchaType = challenge.type || state.config.type;
|
||||
state.root.dataset.captchaResolvedImageStyle = challenge.metadata && challenge.metadata.imageStyle
|
||||
? String(challenge.metadata.imageStyle)
|
||||
: state.config.imageStyle;
|
||||
text(state.root, "[data-captcha-prompt]", challenge.prompt || "Complete the security challenge.");
|
||||
|
||||
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
|
||||
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
|
||||
show(promptRow, !notRobot);
|
||||
show(notRobotPanel, notRobot);
|
||||
|
||||
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
|
||||
var image = state.root.querySelector("[data-captcha-image]");
|
||||
if (!notRobot && challenge.image && image) {
|
||||
image.src = challenge.image;
|
||||
show(imageWrap, true);
|
||||
} else {
|
||||
if (image) image.removeAttribute("src");
|
||||
show(imageWrap, false);
|
||||
}
|
||||
|
||||
if (notRobot) {
|
||||
var items = state.root.querySelector("[data-captcha-items]");
|
||||
if (items) items.replaceChildren();
|
||||
show(items, false);
|
||||
} else {
|
||||
renderItems(state, challenge);
|
||||
}
|
||||
|
||||
var answerWrap = state.root.querySelector("[data-captcha-answer-wrap]");
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
var needsAnswer = !notRobot && !challenge.items && challenge.inputMode !== "none";
|
||||
show(answerWrap, needsAnswer);
|
||||
if (answer) {
|
||||
answer.value = "";
|
||||
answer.inputMode = challenge.inputMode === "numeric" ? "numeric" : "text";
|
||||
answer.setAttribute("aria-invalid", "false");
|
||||
}
|
||||
|
||||
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
|
||||
if (honeypot) honeypot.name = challenge.honeypotField || "";
|
||||
|
||||
setStatus(state, "ready", "");
|
||||
startTimer(state);
|
||||
emit(state, "challenge", challenge);
|
||||
emit(state, "ready");
|
||||
|
||||
if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
|
||||
window.setTimeout(function () {
|
||||
verify(state);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function createChallenge(state, requestedPresentation) {
|
||||
if (state.config.disabled) return;
|
||||
|
||||
if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
|
||||
await mountExternal(state);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer(state);
|
||||
resetUi(state);
|
||||
setStatus(state, "loading", "");
|
||||
|
||||
try {
|
||||
var response = await fetch(state.config.endpoint, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
siteKey: state.config.siteKey,
|
||||
action: state.config.action,
|
||||
type: state.config.type,
|
||||
presentation: requestedPresentation || state.config.presentation,
|
||||
difficulty: state.config.difficulty,
|
||||
disturbance: state.config.disturbance,
|
||||
imageStyle: state.config.imageStyle,
|
||||
allowedStyles: state.config.allowedStyles,
|
||||
excludedStyles: state.config.excludedStyles,
|
||||
randomizeStyle: state.config.randomizeStyle,
|
||||
locale: state.config.locale,
|
||||
responseField: state.config.responseField,
|
||||
}),
|
||||
});
|
||||
|
||||
var result = await response.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response.ok || !result || !result.id) {
|
||||
throw new Error(result && result.message ? result.message : "Challenge request failed");
|
||||
}
|
||||
|
||||
renderChallenge(state, result);
|
||||
} catch (error) {
|
||||
setStatus(state, "network-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(state) {
|
||||
state.notRobotPending = false;
|
||||
if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.challenge) {
|
||||
await createChallenge(state);
|
||||
return;
|
||||
}
|
||||
|
||||
var answerInput = state.root.querySelector("[data-captcha-answer]");
|
||||
var honeypotInput = state.root.querySelector("[data-captcha-honeypot]");
|
||||
state.answer = answerInput ? answerInput.value : "";
|
||||
|
||||
setStatus(state, "verifying", "");
|
||||
emit(state, "verify");
|
||||
|
||||
try {
|
||||
var response = await fetch(state.challenge.verifyUrl || state.config.verifyEndpoint, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
challengeId: state.challenge.id,
|
||||
action: state.config.action,
|
||||
answer: state.answer,
|
||||
selections: state.selections,
|
||||
honeypot: honeypotInput ? honeypotInput.value : "",
|
||||
timingToken: state.challenge.timingToken || "",
|
||||
}),
|
||||
});
|
||||
|
||||
var result = await response.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response.ok || !result || !result.success) {
|
||||
setResponseToken(state, "");
|
||||
var expired = Boolean(result && result.code === "expired");
|
||||
setStatus(
|
||||
state,
|
||||
expired ? "expired" : "incorrect",
|
||||
expired
|
||||
? state.config.expiredMessage
|
||||
: result && result.message
|
||||
? result.message
|
||||
: state.config.incorrectMessage,
|
||||
);
|
||||
emit(state, "failure", result || null);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer(state);
|
||||
setResponseToken(state, result.responseToken || "");
|
||||
setStatus(state, "verified", "Verification completed.");
|
||||
emit(state, "success", result);
|
||||
} catch (error) {
|
||||
setStatus(state, "network-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function verifyNotRobot(state) {
|
||||
if (state.config.disabled || state.notRobotPending || state.status !== "ready") return;
|
||||
if (!state.challenge || state.challenge.type !== "not-robot") {
|
||||
createChallenge(state);
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredMinimum = Number(
|
||||
state.challenge.metadata && state.challenge.metadata.minCompletionMs !== undefined
|
||||
? state.challenge.metadata.minCompletionMs
|
||||
: 800,
|
||||
);
|
||||
var minimum = Number.isFinite(configuredMinimum) ? Math.max(0, configuredMinimum) : 800;
|
||||
var elapsed = Math.max(0, Date.now() - state.challengeLoadedAt);
|
||||
var remaining = Math.max(0, minimum - elapsed);
|
||||
|
||||
state.notRobotPending = true;
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
emit(state, "input", { checked: true });
|
||||
|
||||
var complete = function () {
|
||||
state.notRobotTimer = null;
|
||||
state.notRobotPending = false;
|
||||
verify(state);
|
||||
};
|
||||
|
||||
if (remaining > 0) state.notRobotTimer = window.setTimeout(complete, remaining);
|
||||
else complete();
|
||||
}
|
||||
|
||||
function providerDefinition(provider) {
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
url: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
||||
globalName: "turnstile",
|
||||
};
|
||||
}
|
||||
if (provider === "recaptcha") {
|
||||
return {
|
||||
url: "https://www.google.com/recaptcha/api.js?render=explicit",
|
||||
globalName: "grecaptcha",
|
||||
};
|
||||
}
|
||||
if (provider === "hcaptcha") {
|
||||
return {
|
||||
url: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
||||
globalName: "hcaptcha",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadProviderScript(definition) {
|
||||
if (window[definition.globalName]) return Promise.resolve(window[definition.globalName]);
|
||||
if (scriptPromises.has(definition.url)) return scriptPromises.get(definition.url);
|
||||
|
||||
var promise = new Promise(function (resolve, reject) {
|
||||
var script = document.querySelector('script[src="' + definition.url + '"]');
|
||||
if (!script) {
|
||||
script = document.createElement("script");
|
||||
script.src = definition.url;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
script.addEventListener(
|
||||
"load",
|
||||
function () {
|
||||
if (window[definition.globalName]) resolve(window[definition.globalName]);
|
||||
else reject(new Error("CAPTCHA provider did not initialize"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
|
||||
});
|
||||
|
||||
scriptPromises.set(definition.url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function mountExternal(state) {
|
||||
var definition = providerDefinition(state.config.provider);
|
||||
if (!definition) return;
|
||||
|
||||
if (!state.config.siteKey) {
|
||||
setStatus(state, "provider-error", "A public site key is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var mount = state.root.querySelector("[data-captcha-provider-mount]");
|
||||
if (!mount) return;
|
||||
|
||||
show(mount, true);
|
||||
setStatus(state, "loading", "");
|
||||
|
||||
try {
|
||||
var api = await loadProviderScript(definition);
|
||||
|
||||
if (state.externalWidgetId !== null && typeof api.reset === "function") {
|
||||
setResponseToken(state, "");
|
||||
api.reset(state.externalWidgetId);
|
||||
setStatus(state, "ready", "");
|
||||
emit(state, "ready");
|
||||
return;
|
||||
}
|
||||
|
||||
mount.replaceChildren();
|
||||
state.externalApi = api;
|
||||
state.externalWidgetId = api.render(mount, {
|
||||
sitekey: state.config.siteKey,
|
||||
theme: "auto",
|
||||
size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
|
||||
action: state.config.action,
|
||||
callback: function (token) {
|
||||
setResponseToken(state, token);
|
||||
setStatus(state, "verified", "Verification completed.");
|
||||
emit(state, "success");
|
||||
},
|
||||
"expired-callback": function () {
|
||||
setResponseToken(state, "");
|
||||
setStatus(state, "expired", state.config.expiredMessage);
|
||||
emit(state, "expired");
|
||||
},
|
||||
"error-callback": function (providerError) {
|
||||
setResponseToken(state, "");
|
||||
setStatus(state, "provider-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: providerError || "provider-error" });
|
||||
},
|
||||
});
|
||||
|
||||
setStatus(state, "ready", "");
|
||||
emit(state, "ready");
|
||||
} catch (error) {
|
||||
setStatus(state, "provider-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function playAudio(state) {
|
||||
if (!state.challenge || !state.challenge.audioUrl || state.config.disabled) return;
|
||||
|
||||
if (state.audioPlayer) {
|
||||
state.audioPlayer.pause();
|
||||
state.audioPlayer.currentTime = 0;
|
||||
}
|
||||
|
||||
var audio = new Audio();
|
||||
state.audioPlayer = audio;
|
||||
audio.preload = "auto";
|
||||
audio.volume = 1;
|
||||
audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
|
||||
|
||||
audio.addEventListener("ended", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
emit(state, "audioEnd");
|
||||
emit(state, "audio-end");
|
||||
}, { once: true });
|
||||
|
||||
audio.addEventListener("error", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
var mediaError = audio.error;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Load a new challenge and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-failed",
|
||||
audioUrl: audio.src,
|
||||
mediaErrorCode: mediaError ? mediaError.code : null,
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
audio.load();
|
||||
audio.play().then(function () {
|
||||
emit(state, "audioStart");
|
||||
emit(state, "audio-start");
|
||||
}).catch(function (error) {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Check the audio endpoint and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-rejected",
|
||||
audioUrl: audio.src,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onFormSubmit(state, event) {
|
||||
if (!state.config.required || state.responseToken) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setStatus(state, "incorrect", state.config.requiredMessage);
|
||||
emit(state, "failure", { code: "missing-input" });
|
||||
var target = state.root.querySelector("[data-captcha-answer]") || state.root.querySelector("[data-captcha-not-robot-button]") || state.root.querySelector("[data-captcha-verify]");
|
||||
if (target && typeof target.focus === "function") target.focus();
|
||||
}
|
||||
|
||||
function initialize(root) {
|
||||
if (!(root instanceof HTMLElement) || states.has(root)) return;
|
||||
|
||||
if (!root.id) {
|
||||
instanceCounter += 1;
|
||||
root.id = "wrn-captcha-" + instanceCounter;
|
||||
}
|
||||
|
||||
var state = {
|
||||
root: root,
|
||||
config: config(root),
|
||||
challenge: null,
|
||||
answer: "",
|
||||
selections: [],
|
||||
responseToken: "",
|
||||
status: "idle",
|
||||
timer: null,
|
||||
notRobotTimer: null,
|
||||
notRobotPending: false,
|
||||
challengeLoadedAt: 0,
|
||||
externalApi: null,
|
||||
externalWidgetId: null,
|
||||
audioPlayer: null,
|
||||
form: root.closest("form"),
|
||||
};
|
||||
states.set(root, state);
|
||||
root.dataset.captchaSize = state.config.size;
|
||||
|
||||
var response = root.querySelector("[data-captcha-response]");
|
||||
if (response) response.name = state.config.responseField;
|
||||
|
||||
var answer = root.querySelector("[data-captcha-answer]");
|
||||
var verifyButton = root.querySelector("[data-captcha-verify]");
|
||||
var refreshButton = root.querySelector("[data-captcha-refresh]");
|
||||
var audioButton = root.querySelector("[data-captcha-audio]");
|
||||
var audioAlternative = root.querySelector("[data-captcha-audio-alternative]");
|
||||
var notRobotButton = root.querySelector("[data-captcha-not-robot-button]");
|
||||
|
||||
if (answer) {
|
||||
answer.addEventListener("input", function () {
|
||||
state.answer = answer.value;
|
||||
emit(state, "input", { answerLength: state.answer.length });
|
||||
});
|
||||
answer.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
verify(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (verifyButton) verifyButton.addEventListener("click", function () { verify(state); });
|
||||
if (notRobotButton) notRobotButton.addEventListener("click", function () { verifyNotRobot(state); });
|
||||
if (refreshButton) refreshButton.addEventListener("click", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
if (audioButton) audioButton.addEventListener("click", function () { playAudio(state); });
|
||||
if (audioAlternative) audioAlternative.addEventListener("click", function () {
|
||||
emit(state, "refresh", { presentation: "audio" });
|
||||
createChallenge(state, "audio");
|
||||
});
|
||||
|
||||
root.addEventListener("captcha-reset", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
|
||||
if (state.form) {
|
||||
state.form.addEventListener("submit", function (event) {
|
||||
onFormSubmit(state, event);
|
||||
}, true);
|
||||
state.form.addEventListener("wire:success", function () {
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
}
|
||||
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
if (state.config.disabled) setStatus(state, "disabled", "");
|
||||
else if (state.config.autoLoad) createChallenge(state, state.config.presentation);
|
||||
}
|
||||
|
||||
function scan(scope) {
|
||||
var host = scope || document;
|
||||
if (host instanceof Element && host.matches("[data-wrn-captcha]")) initialize(host);
|
||||
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
|
||||
}
|
||||
|
||||
var runtime = {
|
||||
scan: scan,
|
||||
reset: function (element) {
|
||||
var root = typeof element === "string" ? document.querySelector(element) : element;
|
||||
var state = root ? states.get(root) : null;
|
||||
if (state) createChallenge(state, state.config.presentation);
|
||||
},
|
||||
verify: function (element) {
|
||||
var root = typeof element === "string" ? document.querySelector(element) : element;
|
||||
var state = root ? states.get(root) : null;
|
||||
return state ? verify(state) : Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
window[RUNTIME_KEY] = runtime;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
|
||||
} else {
|
||||
scan(document);
|
||||
}
|
||||
|
||||
new MutationObserver(function (records) {
|
||||
records.forEach(function (record) {
|
||||
record.addedNodes.forEach(function (node) {
|
||||
if (node.nodeType === 1) scan(node);
|
||||
});
|
||||
});
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -3,6 +3,14 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const showcaseRoot = join(import.meta.dir, "..");
|
||||
const repositoryRoot = join(showcaseRoot, "..", "..");
|
||||
const packageComponentPath = join(
|
||||
repositoryRoot,
|
||||
"packages",
|
||||
"captcha",
|
||||
"components",
|
||||
"Captcha.wrn",
|
||||
);
|
||||
|
||||
function path(relativePath: string): string {
|
||||
return join(showcaseRoot, relativePath);
|
||||
@@ -19,7 +27,6 @@ describe("CAPTCHA showcase structure", () => {
|
||||
"app/pages/captcha.wrn",
|
||||
"app/pages/protected.wrn",
|
||||
"app/pages/styles.wrn",
|
||||
"app/components/Captcha.wrn",
|
||||
"app/api/contact.ts",
|
||||
"app/api/page-grant.ts",
|
||||
"app/schemas/contact.ts",
|
||||
@@ -41,7 +48,8 @@ describe("CAPTCHA showcase structure", () => {
|
||||
expect(packageJson).toContain('"@iconify-json/lucide"');
|
||||
expect(packageJson).toContain('"@iconify/tailwind4"');
|
||||
expect(styles).toContain('@plugin "@iconify/tailwind4";');
|
||||
expect(styles).toContain('@source "../../../packages/captcha/components/*.wrn";');
|
||||
expect(styles).not.toContain("packages/captcha/components");
|
||||
expect(read("wrnexus.config.ts")).not.toContain("includePackageSources: false");
|
||||
});
|
||||
|
||||
test("documents every bundled challenge mode", () => {
|
||||
@@ -72,7 +80,6 @@ describe("CAPTCHA showcase structure", () => {
|
||||
expect(source).toContain('showListen="false"');
|
||||
});
|
||||
|
||||
|
||||
test("documents every generated image renderer and random pool controls", () => {
|
||||
const source = read("app/pages/styles.wrn");
|
||||
const styles = [
|
||||
@@ -170,15 +177,23 @@ describe("CAPTCHA showcase integration", () => {
|
||||
expect(grantRoute).toContain("Response.redirect");
|
||||
});
|
||||
|
||||
test("registers the CAPTCHA plugin and DevToolbar audit", () => {
|
||||
const source = read("wrnexus.config.ts");
|
||||
test("auto-discovers the CAPTCHA package without manual plugin or script wiring", () => {
|
||||
const config = read("wrnexus.config.ts");
|
||||
const packageJson = read("package.json");
|
||||
const component = readFileSync(packageComponentPath, "utf8");
|
||||
|
||||
expect(source).toContain("captchaPlugin");
|
||||
expect(source).toContain("enableDevToolbar: true");
|
||||
expect(packageJson).toContain('"@wrnexus/captcha": "workspace:*"');
|
||||
expect(existsSync(path("app/components/Captcha.wrn"))).toBe(false);
|
||||
expect(existsSync(packageComponentPath)).toBe(true);
|
||||
expect(config).not.toContain("captchaPlugin");
|
||||
expect(config).not.toContain("plugins:");
|
||||
expect(component).toContain('data-wrnexus-runtime="captcha"');
|
||||
expect(component).not.toContain("<script");
|
||||
expect(component).not.toContain("captcha.js");
|
||||
});
|
||||
|
||||
test("ships the expected component events and response field", () => {
|
||||
const source = read("app/components/Captcha.wrn");
|
||||
const source = readFileSync(packageComponentPath, "utf8");
|
||||
const expectedEvents = [
|
||||
"ready",
|
||||
"challenge",
|
||||
@@ -195,14 +210,14 @@ describe("CAPTCHA showcase integration", () => {
|
||||
|
||||
expect(source).toContain('name = "wrn-captcha-response"');
|
||||
expect(source).toContain('responseField = "wrn-captcha-response"');
|
||||
expect(source).toContain('disturbance = 50');
|
||||
expect(source).toContain("disturbance = 50");
|
||||
expect(source).toContain('size = "normal"');
|
||||
expect(source).toContain('showListen = true');
|
||||
expect(source).toContain("showListen = true");
|
||||
expect(source).toContain('imageStyle = "random"');
|
||||
expect(source).toContain('allowedStyles = ""');
|
||||
expect(source).toContain('excludedStyles = ""');
|
||||
expect(source).toContain('randomizeStyle = false');
|
||||
expect(source).toContain('data-captcha-not-robot-button');
|
||||
expect(source).toContain("randomizeStyle = false");
|
||||
expect(source).toContain("data-captcha-not-robot-button");
|
||||
|
||||
for (const eventName of expectedEvents) {
|
||||
expect(source).toContain(`@event ${eventName} = function`);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
import { captchaPlugin } from "@wrnexus/captcha/plugin";
|
||||
|
||||
const config: AppConfig & { plugins?: unknown[] } = {
|
||||
const config: AppConfig = {
|
||||
seo: {
|
||||
title: "WRNexus CAPTCHA Showcase",
|
||||
description: "Number, text, calculation, image, audio, invisible, managed, and third-party CAPTCHA examples.",
|
||||
description:
|
||||
"Number, text, calculation, image, audio, invisible, managed, and third-party CAPTCHA examples.",
|
||||
canonicalBase: "http://localhost:3000",
|
||||
robots: "noindex,nofollow",
|
||||
},
|
||||
@@ -12,12 +12,21 @@ const config: AppConfig & { plugins?: unknown[] } = {
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
process: async ({ entryPath, appRoot, mode }) => {
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||
if (!entryPath) throw new Error("CAPTCHA showcase stylesheet entry was not resolved.");
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath];
|
||||
if (mode === "production") args.push("--minify");
|
||||
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||||
const result = await Bun.$.cwd(appRoot).nothrow()`bunx ${args}`;
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.toString().trim() ||
|
||||
result.stdout.toString().trim() ||
|
||||
"Tailwind CSS processing failed.",
|
||||
);
|
||||
}
|
||||
return result.stdout.toString();
|
||||
},
|
||||
failureMode: "throw",
|
||||
},
|
||||
plugins: [captchaPlugin({ enableDevToolbar: true })],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -46,11 +46,11 @@ page AdvancedFormsShowcase {
|
||||
<article class="catalog-card">
|
||||
<div class="catalog-card-preview">
|
||||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||||
<div class="catalog-card-stage"><div data-component="InputNumber" size="md" color="primary" name="default-quantity" value="1" min="0" max="99" step="1"></div></div>
|
||||
<div class="catalog-card-stage"><div data-component="InputNumber" size="md" color="primary" variant="default" name="default-quantity" value="1" min="0" max="99" step="1"></div></div>
|
||||
</div>
|
||||
<div class="catalog-card-body">
|
||||
<div><span class="catalog-card-category">Advanced Forms</span><h2>Input Number</h2><p>Theme-aware, responsive input number component.</p></div>
|
||||
<div class="catalog-card-footer"><span>13 props · 0 slots</span><a href="/components/input-number">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>40 props · 0 slots</span><a href="/components/input-number">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card">
|
||||
|
||||
@@ -10,7 +10,7 @@ page BaseShowcase {
|
||||
<span class="showcase-eyebrow">Component category</span>
|
||||
<h1 class="showcase-title">Base</h1>
|
||||
<p class="showcase-description">27 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p>
|
||||
<div class="category-meta"><span>27 components</span><span>Multiple use cases</span><span>81 live configurations</span></div>
|
||||
<div class="category-meta"><span>27 components</span><span>Multiple use cases</span><span>86 live configurations</span></div>
|
||||
</header>
|
||||
<div class="catalog-grid">
|
||||
<article class="catalog-card">
|
||||
|
||||
@@ -70,7 +70,7 @@ page ButtonDetail {
|
||||
<section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list">
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Recommended</span><h2>Production default</h2><p>Balanced spacing, hierarchy, and content for the most common product workflow.</p></div>
|
||||
<div><span class="demo-case-eyebrow">Default</span><h2>Primary action</h2><p>The standard high-emphasis action for forms and product workflows.</p></div>
|
||||
<span class="demo-case-number">01</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
@@ -78,7 +78,7 @@ page ButtonDetail {
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Save & Submit" as="button" type="submit" class="showcase-instance showcase-instance--1"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Production default usage">
|
||||
<section class="demo-code" aria-label="Primary action usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Save & Submit"
|
||||
@@ -90,7 +90,7 @@ page ButtonDetail {
|
||||
</article>
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Dense UI</span><h2>Compact application</h2><p>A tighter variation for dashboards, side panels, tables, and operational interfaces.</p></div>
|
||||
<div><span class="demo-case-eyebrow">Outline · Small</span><h2>Secondary navigation</h2><p>A compact link button with an end icon and restrained emphasis.</p></div>
|
||||
<span class="demo-case-number">02</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
@@ -98,7 +98,7 @@ page ButtonDetail {
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Learn more" as="a" href="#button-demo" variant="outline" size="sm" icon="icon-[lucide--arrow-right]" iconPosition="end" class="showcase-instance showcase-instance--2"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Compact application usage">
|
||||
<section class="demo-code" aria-label="Secondary navigation usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Learn more"
|
||||
@@ -114,7 +114,7 @@ page ButtonDetail {
|
||||
</article>
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Extended</span><h2>Rich configuration</h2><p>A more expressive variation using additional data, stronger emphasis, and optional states.</p></div>
|
||||
<div><span class="demo-case-eyebrow">Secondary · Large · Pill</span><h2>Prominent rounded action</h2><p>A large secondary action using the optional pill shape.</p></div>
|
||||
<span class="demo-case-number">03</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
@@ -122,7 +122,7 @@ page ButtonDetail {
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Upgrade plan" as="button" variant="secondary" size="lg" pill="true" class="showcase-instance showcase-instance--3"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Rich configuration usage">
|
||||
<section class="demo-code" aria-label="Prominent rounded action usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Upgrade plan"
|
||||
@@ -130,6 +130,113 @@ page ButtonDetail {
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
pill="true"
|
||||
/></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Ghost · Icon</span><h2>Accessible icon action</h2><p>An icon-only control with its accessible name supplied through props.</p></div>
|
||||
<span class="demo-case-number">04</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
<div id="button-demo-4" class="demo-canvas demo-canvas--4">
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Open settings" as="button" variant="ghost" size="icon" icon="icon-[lucide--settings]" ariaLabel="Open settings" class="showcase-instance showcase-instance--4"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Accessible icon action usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Open settings"
|
||||
as="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon="icon-[lucide--settings]"
|
||||
ariaLabel="Open settings"
|
||||
/></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Destructive</span><h2>Dangerous action</h2><p>A destructive treatment for irreversible or high-risk operations.</p></div>
|
||||
<span class="demo-case-number">05</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
<div id="button-demo-5" class="demo-canvas demo-canvas--5">
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Delete project" as="button" variant="destructive" icon="icon-[lucide--trash-2]" class="showcase-instance showcase-instance--5"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Dangerous action usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Delete project"
|
||||
as="button"
|
||||
variant="destructive"
|
||||
icon="icon-[lucide--trash-2]"
|
||||
/></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Link</span><h2>Inline action</h2><p>A low-chrome action that behaves and reads like an inline link.</p></div>
|
||||
<span class="demo-case-number">06</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
<div id="button-demo-6" class="demo-canvas demo-canvas--6">
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Read documentation" as="a" href="#button-demo" variant="link" class="showcase-instance showcase-instance--6"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Inline action usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Read documentation"
|
||||
as="a"
|
||||
href="#button-demo"
|
||||
variant="link"
|
||||
/></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Loading</span><h2>Pending action</h2><p>The button owns its busy state, loading label, spinner, and disabled behavior.</p></div>
|
||||
<span class="demo-case-number">07</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
<div id="button-demo-7" class="demo-canvas demo-canvas--7">
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Publish" loadingLabel="Publishing…" as="button" loading="true" class="showcase-instance showcase-instance--7"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Pending action usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Publish"
|
||||
loadingLabel="Publishing…"
|
||||
as="button"
|
||||
loading="true"
|
||||
/></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
<article class="demo-case">
|
||||
<header class="demo-case-header">
|
||||
<div><span class="demo-case-eyebrow">Disabled</span><h2>Unavailable action</h2><p>A visibly and semantically disabled control.</p></div>
|
||||
<span class="demo-case-number">08</span>
|
||||
</header>
|
||||
<div class="demo-workbench">
|
||||
<div id="button-demo-8" class="demo-canvas demo-canvas--8">
|
||||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||||
<div class="demo-render"><div data-component="Button" label="Unavailable action" as="button" variant="outline" disabled="true" class="showcase-instance showcase-instance--8"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Unavailable action usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><Button
|
||||
label="Unavailable action"
|
||||
as="button"
|
||||
variant="outline"
|
||||
disabled="true"
|
||||
/></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
@@ -138,7 +245,7 @@ page ButtonDetail {
|
||||
<section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>label</code></td><td>string</td><td><code>"Button"</code></td><td>No</td></tr><tr><td><code>loadingLabel</code></td><td>string</td><td><code>"Loading…"</code></td><td>No</td></tr><tr><td><code>description</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>as</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>href</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>target</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>rel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>type</code></td><td>string</td><td><code>"button"</code></td><td>No</td></tr><tr><td><code>variant</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>disabled</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>loading</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>pill</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>fullWidth</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>icon</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>iconPosition</code></td><td>string</td><td><code>"start"</code></td><td>No</td></tr><tr><td><code>ariaLabel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaPressed</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaExpanded</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaControls</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>title</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>autofocus</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>controlClass</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section>
|
||||
</main>
|
||||
<aside class="detail-aside">
|
||||
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#button-demo-1">Production default</a><a href="#button-demo-2">Compact application</a><a href="#button-demo-3">Rich configuration</a><a href="#api">Props API</a></div>
|
||||
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#button-demo-1">Primary action</a><a href="#button-demo-2">Secondary navigation</a><a href="#button-demo-3">Prominent rounded action</a><a href="#button-demo-4">Accessible icon action</a><a href="#button-demo-5">Dangerous action</a><a href="#button-demo-6">Inline action</a><a href="#button-demo-7">Pending action</a><a href="#button-demo-8">Unavailable action</a><a href="#api">Props API</a></div>
|
||||
</aside>
|
||||
</div>
|
||||
<nav class="detail-pagination">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,11 +17,11 @@ page ComponentShowcase {
|
||||
<section class="home-product-grid"><article><span class="icon-[lucide--component] size-7"></span><strong>85 components</strong><p>Accessible primitives for every product surface.</p><a href="/base">Browse components →</a></article><article><span class="icon-[lucide--layout-template] size-7"></span><strong>Ready-made blocks</strong><p>Composable sections assembled from WRNexus UI.</p><a href="/blocks">Explore blocks →</a></article><article><span class="icon-[lucide--panels-top-left] size-7"></span><strong>Page templates</strong><p>Complete responsive pages ready to customize.</p><a href="/templates">View templates →</a></article></section>
|
||||
<section class="showcase-stats">
|
||||
<div class="showcase-stat"><strong>85</strong><span>Unique components</span></div>
|
||||
<div class="showcase-stat"><strong>343</strong><span>Live configurations</span></div>
|
||||
<div class="showcase-stat"><strong>348</strong><span>Live configurations</span></div>
|
||||
<div class="showcase-stat"><strong>0</strong><span>Duplicate implementations</span></div>
|
||||
<div class="showcase-stat"><strong>8</strong><span>Focused categories</span></div>
|
||||
</section>
|
||||
<section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>9 components · 115 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 81 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/forms"><span class="home-category-index">03</span><div><strong>Forms</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/integrations"><span class="home-category-index">04</span><div><strong>Integrations</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/layout"><span class="home-category-index">05</span><div><strong>Layout</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/navigation"><span class="home-category-index">06</span><div><strong>Navigation</strong><p>9 components · 27 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/overlays"><span class="home-category-index">07</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/tables"><span class="home-category-index">08</span><div><strong>Tables</strong><p>1 components · 3 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section>
|
||||
<section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>9 components · 115 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 86 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/forms"><span class="home-category-index">03</span><div><strong>Forms</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/integrations"><span class="home-category-index">04</span><div><strong>Integrations</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/layout"><span class="home-category-index">05</span><div><strong>Layout</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/navigation"><span class="home-category-index">06</span><div><strong>Navigation</strong><p>9 components · 27 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/overlays"><span class="home-category-index">07</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/tables"><span class="home-category-index">08</span><div><strong>Tables</strong><p>1 components · 3 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1269,6 +1269,9 @@ function inputNumberConfiguration(variation) {
|
||||
|
||||
function demoUses(component) {
|
||||
switch (component.name) {
|
||||
case "Button":
|
||||
return buttonUses;
|
||||
|
||||
case "AdvancedSelect":
|
||||
return advancedSelectUses;
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ test("every component has a detail page with its expected live use cases", () =>
|
||||
return [...source.matchAll(/<div data-component="([^"]+)"/g)].map((match) => match[1]);
|
||||
});
|
||||
|
||||
// Every detail page has one playground plus three standard demos. Button,
|
||||
// AdvancedSelect, ComboBox, PinInput, and TogglePassword add specialized capability demos.
|
||||
expect(detailMounts).toHaveLength(reference.count * 4 + 83);
|
||||
// Every detail page has one playground plus three standard demos. Interactive
|
||||
// components add specialized capability demos beyond that shared baseline.
|
||||
expect(detailMounts).toHaveLength(reference.count * 4 + 95);
|
||||
for (const component of reference.components) {
|
||||
const expectedMounts =
|
||||
component.mount === "Button"
|
||||
@@ -96,11 +96,15 @@ test("every component has a detail page with its expected live use cases", () =>
|
||||
? 42
|
||||
: component.mount === "ComboBox"
|
||||
? 22
|
||||
: component.mount === "PinInput"
|
||||
? 19
|
||||
: component.mount === "TogglePassword"
|
||||
? 11
|
||||
: 4;
|
||||
: component.mount === "InputNumber"
|
||||
? 13
|
||||
: component.mount === "PinInput"
|
||||
? 19
|
||||
: component.mount === "StrongPassword"
|
||||
? 7
|
||||
: component.mount === "TogglePassword"
|
||||
? 11
|
||||
: 4;
|
||||
expect(detailMounts.filter((mount) => mount === component.mount)).toHaveLength(expectedMounts);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
+10
-13
@@ -1,19 +1,16 @@
|
||||
# Framework integration
|
||||
|
||||
The current WRNexusJS component scanner always includes `packages/ui/components` and the application’s `app/components`. The installer therefore copies `Captcha.wrn` into `packages/ui/components/Captcha.wrn` while retaining the canonical package copy at `packages/captcha/components/Captcha.wrn`.
|
||||
WRNexusJS 0.4 discovers components and browser runtimes from installed package plugins.
|
||||
|
||||
A future framework improvement can read component directories registered by plugins and pass them to `createRouter({ componentDirs })`. `captchaPlugin()` already exposes the package directory through configuration and plugin metadata, so the package is ready for that change without changing its public API.
|
||||
`@wrnexus/captcha` is a complete example:
|
||||
|
||||
The installer adds these TypeScript aliases:
|
||||
- `packages/captcha/package.json` declares `wrnexus.plugin`.
|
||||
- `captchaPlugin()` contributes its component directory, client runtime, Tailwind source, and DevToolbar audit panel.
|
||||
- `Captcha.wrn` renders `data-wrnexus-runtime="captcha"`.
|
||||
- SSR injects the runtime only when the component appears.
|
||||
- CSR navigation mounts and unmounts the runtime.
|
||||
- Development serves the package source; production emits a hashed chunk.
|
||||
|
||||
```json
|
||||
{
|
||||
"@wrnexus/captcha": ["./packages/captcha/src/index.ts"],
|
||||
"@wrnexus/captcha/server": ["./packages/captcha/src/server/index.ts"],
|
||||
"@wrnexus/captcha/client": ["./packages/captcha/src/client/index.ts"],
|
||||
"@wrnexus/captcha/plugin": ["./packages/captcha/src/plugin.ts"],
|
||||
"@wrnexus/captcha/*": ["./packages/captcha/src/*"]
|
||||
}
|
||||
```
|
||||
There is intentionally no `packages/ui/components/Captcha.wrn`, no showcase-local copy, and no public `captcha.js` file.
|
||||
|
||||
After installation, regenerate the UI component reference so `Captcha` appears in generated component documentation.
|
||||
`integration/platform-upgrade.test.ts` exercises the 0.4 cross-package foundation.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
HealthRegistry,
|
||||
ServiceContainer,
|
||||
memoryIdempotencyStore,
|
||||
serviceToken,
|
||||
withIdempotency,
|
||||
} from "@wrnexus/core";
|
||||
import { signal, watch } from "@wrnexus/reactive";
|
||||
import { routeName } from "@wrnexus/router";
|
||||
import { backoffDelay, retry, stableStringify } from "@wrnexus/helpers";
|
||||
import { createDurableQueue } from "@wrnexus/queue";
|
||||
import { PresenceChannel } from "@wrnexus/pubsub";
|
||||
import { safeObjectKey, sniffContentType } from "@wrnexus/uploader";
|
||||
import { auditWireTokens, contrast } from "@wrnexus/styles";
|
||||
import { createPluginRunner, definePlugin, defaultClientRuntimePath } from "@wrnexus/plugin";
|
||||
import { WRN_SYNTAX_VERSION, createSourceRange, sliceSource } from "@wrnexus/syntax";
|
||||
|
||||
describe("WRNexusJS 0.4 platform upgrades", () => {
|
||||
test("publishes the 0.4 syntax contract", () => {
|
||||
expect(WRN_SYNTAX_VERSION).toBe("0.4");
|
||||
expect(sliceSource("abcdef", createSourceRange(1, 4))).toBe("bcd");
|
||||
});
|
||||
|
||||
test("supports scoped services and health checks", async () => {
|
||||
const token = serviceToken<{ name: string }>("example");
|
||||
const root = new ServiceContainer().set(token, { name: "root" });
|
||||
expect(root.scope().get(token).name).toBe("root");
|
||||
|
||||
const health = new HealthRegistry();
|
||||
health.register("database", () => ({ status: "up" }));
|
||||
expect((await health.check()).status).toBe("up");
|
||||
});
|
||||
|
||||
test("replays idempotent work", async () => {
|
||||
const store = memoryIdempotencyStore<number>();
|
||||
let executions = 0;
|
||||
const execute = async () => ++executions;
|
||||
expect((await withIdempotency(store, "request-1", execute)).replayed).toBe(false);
|
||||
expect((await withIdempotency(store, "request-1", execute)).replayed).toBe(true);
|
||||
expect(executions).toBe(1);
|
||||
});
|
||||
|
||||
test("runs watchers with cleanup", () => {
|
||||
const value = signal(1);
|
||||
const seen: number[] = [];
|
||||
const stop = watch(
|
||||
() => value.get(),
|
||||
(next) => {
|
||||
seen.push(next);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
value.set(2);
|
||||
stop();
|
||||
value.set(3);
|
||||
expect(seen).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("provides deterministic routing and resilience helpers", async () => {
|
||||
expect(routeName("/teams/[teamId]/members/[memberId]")).toBe("teams.teamId.members.memberId");
|
||||
expect(stableStringify({ b: 2, a: 1 })).toBe('{"a":1,"b":2}');
|
||||
expect(backoffDelay(1, { minDelayMs: 10, jitter: 0 })).toBe(10);
|
||||
|
||||
let attempts = 0;
|
||||
const result = await retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 2) throw new Error("retry");
|
||||
return "ok";
|
||||
},
|
||||
{ attempts: 2, minDelayMs: 0, jitter: 0 },
|
||||
);
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
test("drains durable jobs and tracks presence", async () => {
|
||||
const queue = createDurableQueue({ now: () => 100 });
|
||||
const output: number[] = [];
|
||||
queue.process<number>("number", async (job) => {
|
||||
output.push(job.data);
|
||||
});
|
||||
await queue.add("number", 7);
|
||||
expect(await queue.drain()).toBe(1);
|
||||
expect(output).toEqual([7]);
|
||||
|
||||
let now = 1;
|
||||
const presence = new PresenceChannel(10, () => now);
|
||||
presence.touch("user-1");
|
||||
expect(presence.list()).toHaveLength(1);
|
||||
now = 12;
|
||||
expect(presence.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("audits uploads and styles", () => {
|
||||
expect(safeObjectKey("../My Report.pdf", "docs")).toMatch(/^docs\//);
|
||||
expect(sniffContentType(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBe(
|
||||
"image/png",
|
||||
);
|
||||
expect(auditWireTokens(":root{--wire-a:#fff}.x{color:var(--wire-b)}").missing).toEqual([
|
||||
"--wire-b",
|
||||
]);
|
||||
expect(contrast("#000", "#fff")?.level).toBe("aaa");
|
||||
});
|
||||
|
||||
test("normalizes and validates package runtimes", async () => {
|
||||
expect(defaultClientRuntimePath("captcha")).toBe("/__wrnexus/assets/captcha.js");
|
||||
const runner = createPluginRunner(
|
||||
definePlugin({
|
||||
name: "test-runtime",
|
||||
clientRuntimes: [{ id: "test", source: "window.test=true" }],
|
||||
}),
|
||||
{
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "test",
|
||||
metadata: new Map(),
|
||||
warn: () => {},
|
||||
},
|
||||
);
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.clientRuntimes[0]?.publicPath).toBe("/__wrnexus/assets/test.js");
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,9 @@
|
||||
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||
on the server and hydrate automatically — you never write client-side JS islands.
|
||||
- **Application interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||
on the server and hydrate automatically. Advanced reusable packages may declare a framework-managed
|
||||
client runtime; applications never copy its JavaScript or add its `<script>` tag manually.
|
||||
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||
|
||||
@@ -77,7 +78,9 @@ component Counter {
|
||||
```
|
||||
|
||||
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||
Components render on the server with their props, then hydrate — no per-component JS.
|
||||
Components render on the server with their props, then hydrate. When an installed package component
|
||||
needs native browser APIs, it marks its runtime with `data-wrnexus-runtime`; WRNexus injects that
|
||||
runtime once and only on pages that use it.
|
||||
|
||||
## The `view { }` block (plain HTML + a few directives)
|
||||
|
||||
@@ -195,6 +198,29 @@ export default defineRoom({
|
||||
```
|
||||
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||
|
||||
## Installed systems and automatic package runtimes
|
||||
|
||||
WRNexus 0.4 discovers framework packages from application dependencies. A package may contribute
|
||||
components, browser runtimes, assets, styles, routes, middleware, migrations, and DevToolbar panels.
|
||||
Do not copy package components or public JavaScript into the application.
|
||||
|
||||
Example CAPTCHA usage after adding `@wrnexus/captcha`:
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
type="alphanumeric"
|
||||
action="contact-submit"
|
||||
size="compact"
|
||||
/>
|
||||
```
|
||||
|
||||
The rendered component declares `data-wrnexus-runtime="captcha"`. SSR injects the hashed runtime
|
||||
automatically, CSR navigation loads it when first needed, and the runtime mounts/unmounts with the page.
|
||||
Server-side CAPTCHA verification is still mandatory.
|
||||
|
||||
Package authors declare integrations through `package.json#wrnexus` or `definePlugin()`; they should
|
||||
never require consumers to edit HTML, Tailwind `@source`, static asset folders, or migration lists.
|
||||
|
||||
## Config (`wrnexus.config.ts`)
|
||||
|
||||
```ts
|
||||
@@ -263,6 +289,9 @@ wrnexus create <name> # scaffold a new app
|
||||
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||
wrnexus generate component <name> | api <path> | schema <name>
|
||||
wrnexus generate system <name> # scaffold a framework-native package system
|
||||
wrnexus inspect packages|plugins|routes|assets|runtimes|styles|migrations|bundle
|
||||
wrnexus doctor # validate app, plugins, assets, runtimes, and migrations
|
||||
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
|
||||
```
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wrnexus",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.",
|
||||
@@ -26,7 +26,9 @@
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --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.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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "latest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -245,3 +245,5 @@ export function createAI(config: AIConfig = {}): AI {
|
||||
|
||||
return { generate, stream, streamResponse };
|
||||
}
|
||||
export { anthropicProvider, aiProvider, createAIClient } from "./providers.ts";
|
||||
export type { AIUsage, AIResult, AIProvider, AIClient, AIClientOptions } from "./providers.ts";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
AIError,
|
||||
createAI,
|
||||
type AI,
|
||||
type AIConfig,
|
||||
type GenerateOptions,
|
||||
type Message,
|
||||
} from "./index.ts";
|
||||
|
||||
export interface AIUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
export interface AIResult<T = string> {
|
||||
value: T;
|
||||
provider: string;
|
||||
model?: string;
|
||||
usage?: AIUsage;
|
||||
finishReason?: string;
|
||||
raw?: unknown;
|
||||
}
|
||||
export interface AIProvider {
|
||||
name: string;
|
||||
generate(prompt: string | Message[], options?: GenerateOptions): Promise<AIResult<string>>;
|
||||
stream?(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions,
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
}
|
||||
export interface AIClientOptions {
|
||||
providers: AIProvider[];
|
||||
fallback?: boolean;
|
||||
onAttempt?: (provider: string, error?: unknown) => void | Promise<void>;
|
||||
}
|
||||
export interface AIClient {
|
||||
generate(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string },
|
||||
): Promise<AIResult<string>>;
|
||||
generateObject<T>(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string; validate?: (value: unknown) => value is T },
|
||||
): Promise<AIResult<T>>;
|
||||
stream(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string },
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
}
|
||||
|
||||
export function anthropicProvider(config: AIConfig = {}): AIProvider {
|
||||
const client = createAI(config);
|
||||
return {
|
||||
name: "anthropic",
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: "anthropic",
|
||||
model: options?.model ?? config.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function aiProvider(name: string, client: AI): AIProvider {
|
||||
return {
|
||||
name,
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: name,
|
||||
model: options?.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonText(value: string): string {
|
||||
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(value);
|
||||
return (fenced?.[1] ?? value).trim();
|
||||
}
|
||||
|
||||
export function createAIClient(options: AIClientOptions): AIClient {
|
||||
if (!options.providers.length) throw new Error("WRN-AI-NO-PROVIDERS");
|
||||
const select = (name?: string) =>
|
||||
name ? options.providers.filter((provider) => provider.name === name) : options.providers;
|
||||
const generate: AIClient["generate"] = async (prompt, callOptions = {}) => {
|
||||
const providers = select(callOptions.provider);
|
||||
if (!providers.length)
|
||||
throw new AIError(`Unknown AI provider: ${callOptions.provider}`, 0, "provider_error");
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
await options.onAttempt?.(provider.name);
|
||||
return await provider.generate(prompt, callOptions);
|
||||
} catch (error) {
|
||||
last = error;
|
||||
await options.onAttempt?.(provider.name, error);
|
||||
if (options.fallback === false || callOptions.provider) throw error;
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
};
|
||||
return {
|
||||
generate,
|
||||
async generateObject<T>(
|
||||
prompt: string | Message[],
|
||||
callOptions: GenerateOptions & {
|
||||
provider?: string;
|
||||
validate?: (value: unknown) => value is T;
|
||||
} = {},
|
||||
) {
|
||||
const result = await generate(prompt, callOptions);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(jsonText(result.value));
|
||||
} catch {
|
||||
throw new AIError("AI response was not valid JSON", 0, "structured_output_error");
|
||||
}
|
||||
if (callOptions.validate && !callOptions.validate(value))
|
||||
throw new AIError(
|
||||
"AI response failed structured output validation",
|
||||
0,
|
||||
"structured_output_error",
|
||||
);
|
||||
return { ...result, value: value as T };
|
||||
},
|
||||
async *stream(prompt, callOptions = {}) {
|
||||
const providers = select(callOptions.provider);
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
if (!provider.stream) continue;
|
||||
try {
|
||||
yield* provider.stream(prompt, callOptions);
|
||||
return;
|
||||
} catch (error) {
|
||||
last = error;
|
||||
if (options.fallback === false || callOptions.provider) throw error;
|
||||
}
|
||||
}
|
||||
if (last) throw last;
|
||||
const result = await generate(prompt, callOptions);
|
||||
yield result.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
import type { Policy, Subject } from "./index.ts";
|
||||
|
||||
export interface AuthorizationDecision {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
policy?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
export type DecisionPolicy<S = Subject, R = unknown> = (
|
||||
subject: S,
|
||||
resource?: R,
|
||||
) => AuthorizationDecision | Promise<AuthorizationDecision>;
|
||||
|
||||
export function allow(reason?: string, metadata?: Record<string, unknown>): AuthorizationDecision {
|
||||
return { allowed: true, reason, metadata };
|
||||
}
|
||||
export function deny(
|
||||
reason = "Forbidden",
|
||||
metadata?: Record<string, unknown>,
|
||||
): AuthorizationDecision {
|
||||
return { allowed: false, reason, metadata };
|
||||
}
|
||||
export function decision<S, R>(
|
||||
name: string,
|
||||
policy: Policy<S, R>,
|
||||
denial = "Policy denied access",
|
||||
): DecisionPolicy<S, R> {
|
||||
return async (subject, resource) => {
|
||||
const allowed = await policy(subject, resource);
|
||||
return {
|
||||
allowed,
|
||||
reason: allowed ? undefined : denial,
|
||||
policy: name,
|
||||
};
|
||||
};
|
||||
}
|
||||
export function owner<SubjectType extends Subject, Resource extends Record<string, unknown>>(
|
||||
subjectKey: keyof SubjectType = "id",
|
||||
resourceKey: keyof Resource | string = "userId",
|
||||
): DecisionPolicy<SubjectType, Resource> {
|
||||
return (subject, resource) =>
|
||||
resource && Object.is(subject[subjectKey], resource[resourceKey as keyof Resource])
|
||||
? allow("resource owner")
|
||||
: deny("resource ownership required");
|
||||
}
|
||||
export function anyDecision<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
|
||||
return async (subject, resource) => {
|
||||
const denied: AuthorizationDecision[] = [];
|
||||
for (const policy of policies) {
|
||||
const result = await policy(subject, resource);
|
||||
if (result.allowed) return result;
|
||||
denied.push(result);
|
||||
}
|
||||
return deny(
|
||||
denied
|
||||
.map((item) => item.reason)
|
||||
.filter(Boolean)
|
||||
.join("; ") || "No policy allowed access",
|
||||
);
|
||||
};
|
||||
}
|
||||
export function allDecisions<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
|
||||
return async (subject, resource) => {
|
||||
for (const policy of policies) {
|
||||
const result = await policy(subject, resource);
|
||||
if (!result.allowed) return result;
|
||||
}
|
||||
return allow("all policies passed");
|
||||
};
|
||||
}
|
||||
export function authorizeDecision(
|
||||
evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
|
||||
): Middleware {
|
||||
return async (ctx, next) => {
|
||||
const result = await evaluate(ctx);
|
||||
if (result.allowed) return next();
|
||||
return Response.json(
|
||||
{ ok: false, error: "Forbidden", reason: result.reason, policy: result.policy },
|
||||
{ status: 403 },
|
||||
);
|
||||
};
|
||||
}
|
||||
export function filterAuthorized<S, R>(
|
||||
subject: S,
|
||||
values: readonly R[],
|
||||
policy: Policy<S, R>,
|
||||
): Promise<R[]> {
|
||||
return Promise.all(
|
||||
values.map(async (value) => ({ value, allowed: await policy(subject, value) })),
|
||||
).then((results) => results.filter((result) => result.allowed).map((result) => result.value));
|
||||
}
|
||||
@@ -128,3 +128,14 @@ export function requireRole(...roles: string[]): Middleware {
|
||||
export function requirePermission(rbac: Rbac, permission: string): Middleware {
|
||||
return authorize((ctx) => rbac.can(ctx.user as Subject | undefined, permission));
|
||||
}
|
||||
export {
|
||||
allow,
|
||||
deny,
|
||||
decision,
|
||||
owner,
|
||||
anyDecision,
|
||||
allDecisions,
|
||||
authorizeDecision,
|
||||
filterAuthorized,
|
||||
} from "./advanced.ts";
|
||||
export type { AuthorizationDecision, DecisionPolicy } from "./advanced.ts";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
any,
|
||||
all,
|
||||
attr,
|
||||
decision,
|
||||
type Policy,
|
||||
} from "../src/index.ts";
|
||||
|
||||
@@ -78,3 +79,17 @@ test("guards: authorize / requireRole / requirePermission", async () => {
|
||||
(await authorize((c) => (c.user as User)?.id === "u1")(ctx({ id: "u1" }), ok)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
test("explainable decisions only include denial reasons when denied", async () => {
|
||||
const policy = decision("owner", (subject: User) => subject.id === "u1");
|
||||
expect(await policy({ id: "u1" })).toEqual({
|
||||
allowed: true,
|
||||
reason: undefined,
|
||||
policy: "owner",
|
||||
});
|
||||
expect(await policy({ id: "u2" })).toEqual({
|
||||
allowed: false,
|
||||
reason: "Policy denied access",
|
||||
policy: "owner",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
A first-class CAPTCHA and anti-automation package for WRNexusJS. It supports self-hosted challenges, a managed WRNexus service, external providers, form submission guards, page gates, accessible audio, adaptive risk checks, and a Tailwind-only `.wrn` component.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/captcha
|
||||
```
|
||||
|
||||
WRNexusJS automatically discovers the package plugin, component, client runtime, styles, and DevToolbar audit. Use `<Captcha />` directly after installation. The browser runtime is injected once only on responses that render a CAPTCHA; no script tag, public-file copy, or manual plugin registration is required. Call `captchaPlugin(options)` explicitly only when an application needs to override the discovered package configuration.
|
||||
|
||||
## Included challenge modes
|
||||
|
||||
- Number, alphabet, and alphanumeric image challenges
|
||||
@@ -197,9 +205,7 @@ export async function POST(ctx) {
|
||||
const validation = await parseBody(contactSchema, ctx.req.clone());
|
||||
if (!validation.ok) return validation.response;
|
||||
|
||||
return guard(ctx, async () =>
|
||||
Response.json({ ok: true, submission: validation.value }),
|
||||
);
|
||||
return guard(ctx, async () => Response.json({ ok: true, submission: validation.value }));
|
||||
}
|
||||
```
|
||||
|
||||
@@ -291,7 +297,7 @@ For direct browser challenge creation, configure the component’s `endpoint` as
|
||||
|
||||
## DevToolbar
|
||||
|
||||
Register `captchaPlugin()` in the WRNexusJS config. The audit panel checks for likely client-side secrets, missing action bindings, missing provider site keys, optional CAPTCHA fields, accessible alternatives, and server-verification reminders.
|
||||
The automatically discovered CAPTCHA plugin registers its DevToolbar audit panel. It checks for likely client-side secrets, missing action bindings, missing provider site keys, optional CAPTCHA fields, accessible alternatives, and server-verification reminders. Explicit `captchaPlugin(options)` registration is needed only to override automatic configuration.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global Audio, CSS, CustomEvent, Element, HTMLElement, MutationObserver, URL, clearInterval, clearTimeout, document, fetch, window */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
@@ -5,6 +6,11 @@
|
||||
var existingRuntime = window[RUNTIME_KEY];
|
||||
|
||||
if (existingRuntime && typeof existingRuntime.scan === "function") {
|
||||
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
||||
window.__wrnexusRuntimes.captcha = {
|
||||
mount: existingRuntime.scan,
|
||||
unmount: existingRuntime.unmount || function () {},
|
||||
};
|
||||
existingRuntime.scan(document);
|
||||
return;
|
||||
}
|
||||
@@ -26,7 +32,9 @@
|
||||
|
||||
function normalizeSize(value, compact) {
|
||||
if (compact) return "compact";
|
||||
var normalized = String(value || "normal").trim().toLowerCase();
|
||||
var normalized = String(value || "normal")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
|
||||
if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
|
||||
return "normal";
|
||||
@@ -35,7 +43,9 @@
|
||||
function commaList(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map(function (item) { return item.trim().toLowerCase(); })
|
||||
.map(function (item) {
|
||||
return item.trim().toLowerCase();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -89,7 +99,8 @@
|
||||
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
|
||||
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
|
||||
expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
|
||||
networkMessage: data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
|
||||
networkMessage:
|
||||
data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,15 +112,18 @@
|
||||
type: challenge && challenge.type ? challenge.type : state.config.type,
|
||||
action: state.config.action,
|
||||
disturbance: state.config.disturbance,
|
||||
imageStyle: challenge && challenge.metadata && challenge.metadata.imageStyle
|
||||
? challenge.metadata.imageStyle
|
||||
: state.config.imageStyle,
|
||||
requestedImageStyle: challenge && challenge.metadata && challenge.metadata.requestedImageStyle
|
||||
? challenge.metadata.requestedImageStyle
|
||||
: state.config.imageStyle,
|
||||
imageStylePool: challenge && challenge.metadata && challenge.metadata.imageStylePool
|
||||
? challenge.metadata.imageStylePool
|
||||
: state.config.allowedStyles,
|
||||
imageStyle:
|
||||
challenge && challenge.metadata && challenge.metadata.imageStyle
|
||||
? challenge.metadata.imageStyle
|
||||
: state.config.imageStyle,
|
||||
requestedImageStyle:
|
||||
challenge && challenge.metadata && challenge.metadata.requestedImageStyle
|
||||
? challenge.metadata.requestedImageStyle
|
||||
: state.config.imageStyle,
|
||||
imageStylePool:
|
||||
challenge && challenge.metadata && challenge.metadata.imageStylePool
|
||||
? challenge.metadata.imageStylePool
|
||||
: state.config.allowedStyles,
|
||||
size: state.config.size,
|
||||
status: state.status,
|
||||
challengeId: challenge && challenge.id ? challenge.id : "",
|
||||
@@ -190,7 +204,14 @@
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
|
||||
show(loading, status === "loading");
|
||||
show(challenge, status !== "loading" && status !== "idle" && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha");
|
||||
show(
|
||||
challenge,
|
||||
status !== "loading" &&
|
||||
status !== "idle" &&
|
||||
state.config.provider !== "turnstile" &&
|
||||
state.config.provider !== "recaptcha" &&
|
||||
state.config.provider !== "hcaptcha",
|
||||
);
|
||||
show(badge, status === "verified");
|
||||
|
||||
if (state.config.showStatus) {
|
||||
@@ -201,16 +222,27 @@
|
||||
show(error, false);
|
||||
}
|
||||
|
||||
if (message && status === "verified") text(state.root, "[data-captcha-success-message]", message);
|
||||
if (message && status === "verified")
|
||||
text(state.root, "[data-captcha-success-message]", message);
|
||||
if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
|
||||
|
||||
if (answer) {
|
||||
answer.disabled = state.config.disabled || status === "verified" || status === "expired" || status === "loading" || status === "verifying";
|
||||
answer.disabled =
|
||||
state.config.disabled ||
|
||||
status === "verified" ||
|
||||
status === "expired" ||
|
||||
status === "loading" ||
|
||||
status === "verifying";
|
||||
answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
|
||||
}
|
||||
|
||||
if (verifyButton) {
|
||||
verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
|
||||
verifyButton.disabled =
|
||||
state.config.disabled ||
|
||||
status === "loading" ||
|
||||
status === "verifying" ||
|
||||
status === "verified" ||
|
||||
status === "expired";
|
||||
}
|
||||
|
||||
show(verifyIcon, status !== "verifying");
|
||||
@@ -291,7 +323,12 @@
|
||||
honeypot.value = "";
|
||||
honeypot.name = "";
|
||||
}
|
||||
if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
|
||||
if (
|
||||
providerMount &&
|
||||
state.config.provider !== "turnstile" &&
|
||||
state.config.provider !== "recaptcha" &&
|
||||
state.config.provider !== "hcaptcha"
|
||||
) {
|
||||
providerMount.replaceChildren();
|
||||
show(providerMount, false);
|
||||
}
|
||||
@@ -304,26 +341,52 @@
|
||||
var refresh = state.root.querySelector("[data-captcha-refresh]");
|
||||
var verify = state.root.querySelector("[data-captcha-verify]");
|
||||
var footer = state.root.querySelector("[data-captcha-footer]");
|
||||
var isExternal = state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha";
|
||||
var isExternal =
|
||||
state.config.provider === "turnstile" ||
|
||||
state.config.provider === "recaptcha" ||
|
||||
state.config.provider === "hcaptcha";
|
||||
var notRobot = isNotRobot(state);
|
||||
var locked = state.config.disabled || state.status === "loading" || state.status === "verifying" || state.notRobotPending;
|
||||
var locked =
|
||||
state.config.disabled ||
|
||||
state.status === "loading" ||
|
||||
state.status === "verifying" ||
|
||||
state.notRobotPending;
|
||||
|
||||
show(footer, !notRobot);
|
||||
show(audio, !notRobot && state.config.showAudio && state.config.showListen && Boolean(challenge && challenge.audioUrl) && state.status !== "verified");
|
||||
show(audioAlternative, !notRobot && state.config.showAudio && Boolean(challenge && challenge.type === "image") && state.status !== "verified");
|
||||
show(
|
||||
audio,
|
||||
!notRobot &&
|
||||
state.config.showAudio &&
|
||||
state.config.showListen &&
|
||||
Boolean(challenge && challenge.audioUrl) &&
|
||||
state.status !== "verified",
|
||||
);
|
||||
show(
|
||||
audioAlternative,
|
||||
!notRobot &&
|
||||
state.config.showAudio &&
|
||||
Boolean(challenge && challenge.type === "image") &&
|
||||
state.status !== "verified",
|
||||
);
|
||||
show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
|
||||
show(verify, !notRobot && state.config.showVerify && !isExternal);
|
||||
|
||||
if (audio) audio.disabled = locked;
|
||||
if (audioAlternative) audioAlternative.disabled = locked;
|
||||
if (refresh) refresh.disabled = locked;
|
||||
if (verify) verify.disabled = locked || state.status === "verified" || state.status === "expired";
|
||||
if (verify)
|
||||
verify.disabled = locked || state.status === "verified" || state.status === "expired";
|
||||
}
|
||||
|
||||
function renderItems(state, challenge) {
|
||||
var container = state.root.querySelector("[data-captcha-items]");
|
||||
var template = state.root.querySelector("[data-captcha-item-template]");
|
||||
if (!container || !template || !Array.isArray(challenge.items) || challenge.items.length === 0) {
|
||||
if (
|
||||
!container ||
|
||||
!template ||
|
||||
!Array.isArray(challenge.items) ||
|
||||
challenge.items.length === 0
|
||||
) {
|
||||
show(container, false);
|
||||
return;
|
||||
}
|
||||
@@ -359,10 +422,14 @@
|
||||
if (selected) {
|
||||
state.selections.splice(index, 1);
|
||||
} else {
|
||||
var maximum = Number(state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0);
|
||||
var maximum = Number(
|
||||
state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0,
|
||||
);
|
||||
if (maximum > 0 && state.selections.length >= maximum) {
|
||||
var removed = state.selections.shift();
|
||||
var previousButton = state.root.querySelector('[data-captcha-item-id="' + CSS.escape(removed) + '"]');
|
||||
var previousButton = state.root.querySelector(
|
||||
'[data-captcha-item-id="' + CSS.escape(removed) + '"]',
|
||||
);
|
||||
if (previousButton) {
|
||||
previousButton.setAttribute("aria-pressed", "false");
|
||||
show(previousButton.querySelector("[data-captcha-item-check]"), false);
|
||||
@@ -376,7 +443,9 @@
|
||||
show(button.querySelector("[data-captcha-item-check]"), nowSelected);
|
||||
emit(state, "input", { selections: state.selections.slice() });
|
||||
|
||||
var minimum = Number(state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0);
|
||||
var minimum = Number(
|
||||
state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0,
|
||||
);
|
||||
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
|
||||
}
|
||||
|
||||
@@ -389,10 +458,15 @@
|
||||
|
||||
var notRobot = challenge.type === "not-robot";
|
||||
state.root.dataset.captchaType = challenge.type || state.config.type;
|
||||
state.root.dataset.captchaResolvedImageStyle = challenge.metadata && challenge.metadata.imageStyle
|
||||
? String(challenge.metadata.imageStyle)
|
||||
: state.config.imageStyle;
|
||||
text(state.root, "[data-captcha-prompt]", challenge.prompt || "Complete the security challenge.");
|
||||
state.root.dataset.captchaResolvedImageStyle =
|
||||
challenge.metadata && challenge.metadata.imageStyle
|
||||
? String(challenge.metadata.imageStyle)
|
||||
: state.config.imageStyle;
|
||||
text(
|
||||
state.root,
|
||||
"[data-captcha-prompt]",
|
||||
challenge.prompt || "Complete the security challenge.",
|
||||
);
|
||||
|
||||
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
|
||||
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
|
||||
@@ -435,7 +509,12 @@
|
||||
emit(state, "challenge", challenge);
|
||||
emit(state, "ready");
|
||||
|
||||
if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
|
||||
if (
|
||||
state.config.autoVerify &&
|
||||
!notRobot &&
|
||||
challenge.inputMode === "none" &&
|
||||
!challenge.items
|
||||
) {
|
||||
window.setTimeout(function () {
|
||||
verify(state);
|
||||
}, 0);
|
||||
@@ -445,7 +524,11 @@
|
||||
async function createChallenge(state, requestedPresentation) {
|
||||
if (state.config.disabled) return;
|
||||
|
||||
if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
|
||||
if (
|
||||
state.config.provider === "turnstile" ||
|
||||
state.config.provider === "recaptcha" ||
|
||||
state.config.provider === "hcaptcha"
|
||||
) {
|
||||
await mountExternal(state);
|
||||
return;
|
||||
}
|
||||
@@ -495,7 +578,13 @@
|
||||
|
||||
async function verify(state) {
|
||||
state.notRobotPending = false;
|
||||
if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
|
||||
if (
|
||||
state.config.disabled ||
|
||||
state.status === "loading" ||
|
||||
state.status === "verifying" ||
|
||||
state.status === "expired" ||
|
||||
state.status === "verified"
|
||||
) {
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
return;
|
||||
@@ -636,7 +725,13 @@
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
|
||||
script.addEventListener(
|
||||
"error",
|
||||
function () {
|
||||
reject(new Error("CAPTCHA provider script failed"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
scriptPromises.set(definition.url, promise);
|
||||
@@ -674,7 +769,12 @@
|
||||
state.externalWidgetId = api.render(mount, {
|
||||
sitekey: state.config.siteKey,
|
||||
theme: "auto",
|
||||
size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
|
||||
size:
|
||||
state.config.size === "compact"
|
||||
? "compact"
|
||||
: state.config.provider === "turnstile"
|
||||
? "flexible"
|
||||
: "normal",
|
||||
action: state.config.action,
|
||||
callback: function (token) {
|
||||
setResponseToken(state, token);
|
||||
@@ -715,36 +815,55 @@
|
||||
audio.volume = 1;
|
||||
audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
|
||||
|
||||
audio.addEventListener("ended", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
emit(state, "audioEnd");
|
||||
emit(state, "audio-end");
|
||||
}, { once: true });
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
emit(state, "audioEnd");
|
||||
emit(state, "audio-end");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
audio.addEventListener("error", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
var mediaError = audio.error;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Load a new challenge and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-failed",
|
||||
audioUrl: audio.src,
|
||||
mediaErrorCode: mediaError ? mediaError.code : null,
|
||||
});
|
||||
}, { once: true });
|
||||
audio.addEventListener(
|
||||
"error",
|
||||
function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
var mediaError = audio.error;
|
||||
setStatus(
|
||||
state,
|
||||
"network-error",
|
||||
"The audio challenge could not be played. Load a new challenge and try again.",
|
||||
);
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-failed",
|
||||
audioUrl: audio.src,
|
||||
mediaErrorCode: mediaError ? mediaError.code : null,
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
audio.load();
|
||||
audio.play().then(function () {
|
||||
emit(state, "audioStart");
|
||||
emit(state, "audio-start");
|
||||
}).catch(function (error) {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Check the audio endpoint and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-rejected",
|
||||
audioUrl: audio.src,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
audio
|
||||
.play()
|
||||
.then(function () {
|
||||
emit(state, "audioStart");
|
||||
emit(state, "audio-start");
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
setStatus(
|
||||
state,
|
||||
"network-error",
|
||||
"The audio challenge could not be played. Check the audio endpoint and try again.",
|
||||
);
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-rejected",
|
||||
audioUrl: audio.src,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onFormSubmit(state, event) {
|
||||
@@ -753,7 +872,10 @@
|
||||
event.stopImmediatePropagation();
|
||||
setStatus(state, "incorrect", state.config.requiredMessage);
|
||||
emit(state, "failure", { code: "missing-input" });
|
||||
var target = state.root.querySelector("[data-captcha-answer]") || state.root.querySelector("[data-captcha-not-robot-button]") || state.root.querySelector("[data-captcha-verify]");
|
||||
var target =
|
||||
state.root.querySelector("[data-captcha-answer]") ||
|
||||
state.root.querySelector("[data-captcha-not-robot-button]") ||
|
||||
state.root.querySelector("[data-captcha-verify]");
|
||||
if (target && typeof target.focus === "function") target.focus();
|
||||
}
|
||||
|
||||
@@ -781,6 +903,8 @@
|
||||
externalWidgetId: null,
|
||||
audioPlayer: null,
|
||||
form: root.closest("form"),
|
||||
formSubmitListener: null,
|
||||
formSuccessListener: null,
|
||||
};
|
||||
states.set(root, state);
|
||||
root.dataset.captchaSize = state.config.size;
|
||||
@@ -808,17 +932,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (verifyButton) verifyButton.addEventListener("click", function () { verify(state); });
|
||||
if (notRobotButton) notRobotButton.addEventListener("click", function () { verifyNotRobot(state); });
|
||||
if (refreshButton) refreshButton.addEventListener("click", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
if (audioButton) audioButton.addEventListener("click", function () { playAudio(state); });
|
||||
if (audioAlternative) audioAlternative.addEventListener("click", function () {
|
||||
emit(state, "refresh", { presentation: "audio" });
|
||||
createChallenge(state, "audio");
|
||||
});
|
||||
if (verifyButton)
|
||||
verifyButton.addEventListener("click", function () {
|
||||
verify(state);
|
||||
});
|
||||
if (notRobotButton)
|
||||
notRobotButton.addEventListener("click", function () {
|
||||
verifyNotRobot(state);
|
||||
});
|
||||
if (refreshButton)
|
||||
refreshButton.addEventListener("click", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
if (audioButton)
|
||||
audioButton.addEventListener("click", function () {
|
||||
playAudio(state);
|
||||
});
|
||||
if (audioAlternative)
|
||||
audioAlternative.addEventListener("click", function () {
|
||||
emit(state, "refresh", { presentation: "audio" });
|
||||
createChallenge(state, "audio");
|
||||
});
|
||||
|
||||
root.addEventListener("captcha-reset", function () {
|
||||
emit(state, "refresh");
|
||||
@@ -826,12 +961,14 @@
|
||||
});
|
||||
|
||||
if (state.form) {
|
||||
state.form.addEventListener("submit", function (event) {
|
||||
state.formSubmitListener = function (event) {
|
||||
onFormSubmit(state, event);
|
||||
}, true);
|
||||
state.form.addEventListener("wire:success", function () {
|
||||
};
|
||||
state.formSuccessListener = function () {
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
};
|
||||
state.form.addEventListener("submit", state.formSubmitListener, true);
|
||||
state.form.addEventListener("wire:success", state.formSuccessListener);
|
||||
}
|
||||
|
||||
updateNotRobotState(state);
|
||||
@@ -846,8 +983,53 @@
|
||||
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
|
||||
}
|
||||
|
||||
function cleanupRoot(root) {
|
||||
var state = states.get(root);
|
||||
if (!state) return;
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
if (state.notRobotTimer) clearTimeout(state.notRobotTimer);
|
||||
if (state.audioPlayer) {
|
||||
try {
|
||||
state.audioPlayer.pause();
|
||||
state.audioPlayer.src = "";
|
||||
} catch {
|
||||
// Best-effort cleanup for browser audio implementations.
|
||||
}
|
||||
}
|
||||
if (state.form && state.formSubmitListener) {
|
||||
state.form.removeEventListener("submit", state.formSubmitListener, true);
|
||||
}
|
||||
if (state.form && state.formSuccessListener) {
|
||||
state.form.removeEventListener("wire:success", state.formSuccessListener);
|
||||
}
|
||||
try {
|
||||
if (state.externalApi && state.externalWidgetId != null) {
|
||||
if (typeof state.externalApi.remove === "function")
|
||||
state.externalApi.remove(state.externalWidgetId);
|
||||
else if (typeof state.externalApi.reset === "function")
|
||||
state.externalApi.reset(state.externalWidgetId);
|
||||
}
|
||||
} catch {
|
||||
// Third-party providers may reject cleanup after navigation.
|
||||
}
|
||||
states.delete(root);
|
||||
}
|
||||
|
||||
function unmount(scope) {
|
||||
var host = scope || document;
|
||||
var roots = [];
|
||||
if (host instanceof Element && host.matches("[data-wrn-captcha]")) roots.push(host);
|
||||
if (host.querySelectorAll)
|
||||
host.querySelectorAll("[data-wrn-captcha]").forEach(function (root) {
|
||||
roots.push(root);
|
||||
});
|
||||
roots.forEach(cleanupRoot);
|
||||
}
|
||||
|
||||
var runtime = {
|
||||
scan: scan,
|
||||
mount: scan,
|
||||
unmount: unmount,
|
||||
reset: function (element) {
|
||||
var root = typeof element === "string" ? document.querySelector(element) : element;
|
||||
var state = root ? states.get(root) : null;
|
||||
@@ -861,9 +1043,17 @@
|
||||
};
|
||||
|
||||
window[RUNTIME_KEY] = runtime;
|
||||
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
||||
window.__wrnexusRuntimes.captcha = { mount: scan, unmount: unmount };
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
function () {
|
||||
scan(document);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
scan(document);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ component Captcha {
|
||||
{...attrs}
|
||||
id='{id}'
|
||||
data-wrn-captcha
|
||||
data-wrnexus-runtime="captcha"
|
||||
data-captcha-provider='{provider}'
|
||||
data-captcha-site-key='{siteKey}'
|
||||
data-captcha-type='{type}'
|
||||
@@ -96,12 +97,6 @@ component Captcha {
|
||||
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}'
|
||||
>
|
||||
<script
|
||||
src="/assets/wrnexus/captcha.js"
|
||||
defer
|
||||
data-wrn-captcha-runtime
|
||||
></script>
|
||||
|
||||
<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'>
|
||||
<div class='min-w-0'>
|
||||
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -49,5 +49,12 @@
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./src/plugin.ts",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +126,7 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
|
||||
|
||||
try {
|
||||
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
|
||||
addCandidate(
|
||||
candidates,
|
||||
join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
|
||||
);
|
||||
addCandidate(candidates, join(dirname(dirname(dirname(packageEntry))), "assets", "audio"));
|
||||
} catch {
|
||||
// The source-relative and cwd fallbacks below still support direct source use.
|
||||
}
|
||||
@@ -140,7 +137,10 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
|
||||
let current = process.cwd();
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
|
||||
addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
|
||||
addCandidate(
|
||||
candidates,
|
||||
join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"),
|
||||
);
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
@@ -190,9 +190,7 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
|
||||
}
|
||||
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
|
||||
|
||||
const clips = await Promise.all(
|
||||
sequence.map((token) => this.load(language, safeToken(token))),
|
||||
);
|
||||
const clips = await Promise.all(sequence.map((token) => this.load(language, safeToken(token))));
|
||||
const first = clips[0]!;
|
||||
|
||||
for (const clip of clips) {
|
||||
@@ -223,17 +221,13 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const bytes = new Uint8Array(
|
||||
await readFile(join(this.assetsDir, language, `${token}.wav`)),
|
||||
);
|
||||
const bytes = new Uint8Array(await readFile(join(this.assetsDir, language, `${token}.wav`)));
|
||||
const parsed = parseWav(bytes);
|
||||
this.cache.set(key, parsed);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAssetAudioRenderer(
|
||||
options?: AssetAudioRendererOptions,
|
||||
): AssetAudioRenderer {
|
||||
export function createAssetAudioRenderer(options?: AssetAudioRendererOptions): AssetAudioRenderer {
|
||||
return new AssetAudioRenderer(options);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,13 @@ export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
answerKind: "text",
|
||||
image: renderTextChallenge(expression, context),
|
||||
inputMode: "numeric",
|
||||
audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
|
||||
audioSequence: [
|
||||
"what",
|
||||
"is",
|
||||
...numberTokens(left),
|
||||
...(OPERATOR_WORDS[operator] ?? []),
|
||||
...numberTokens(right),
|
||||
],
|
||||
metadata: { operator, imageStyle: context.imageStyle },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
CaptchaImageItem,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawLine,
|
||||
fillCircle,
|
||||
fillPolygon,
|
||||
fillRect,
|
||||
pngDataUri,
|
||||
setPixel,
|
||||
} from "./png.ts";
|
||||
|
||||
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
|
||||
type Shape = (typeof SHAPES)[number];
|
||||
@@ -39,9 +47,26 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
if (shape === "circle") fillCircle(image, cx, cy, size, color);
|
||||
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
|
||||
else if (shape === "triangle") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
|
||||
fillPolygon(
|
||||
image,
|
||||
[
|
||||
[cx, cy - size],
|
||||
[cx - size, cy + size],
|
||||
[cx + size, cy + size],
|
||||
],
|
||||
color,
|
||||
);
|
||||
} else if (shape === "diamond") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
|
||||
fillPolygon(
|
||||
image,
|
||||
[
|
||||
[cx, cy - size],
|
||||
[cx - size, cy],
|
||||
[cx, cy + size],
|
||||
[cx + size, cy],
|
||||
],
|
||||
color,
|
||||
);
|
||||
} else {
|
||||
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
|
||||
}
|
||||
@@ -49,12 +74,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const dots = Math.round(35 + ratio * 150);
|
||||
for (let index = 0; index < dots; index++) {
|
||||
setPixel(
|
||||
image,
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
|
||||
);
|
||||
setPixel(image, context.randomInt(0, 95), context.randomInt(0, 71), [
|
||||
context.randomInt(105, 225),
|
||||
context.randomInt(105, 225),
|
||||
context.randomInt(105, 225),
|
||||
Math.round(55 + ratio * 65),
|
||||
]);
|
||||
}
|
||||
|
||||
const lines = Math.round(1 + ratio * 4);
|
||||
@@ -65,7 +90,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
context.randomInt(0, 71),
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
|
||||
[
|
||||
context.randomInt(100, 210),
|
||||
context.randomInt(100, 210),
|
||||
context.randomInt(100, 210),
|
||||
Math.round(45 + ratio * 55),
|
||||
],
|
||||
ratio > 0.75 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
@@ -105,7 +135,11 @@ export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
}
|
||||
|
||||
shuffle(entries, context);
|
||||
const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
|
||||
const answer = entries
|
||||
.filter((entry) => entry.shape === target)
|
||||
.map((entry) => entry.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
return {
|
||||
type: "image",
|
||||
presentation: "visual",
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { CaptchaChallengeGenerator } from "../types.ts";
|
||||
import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
|
||||
import {
|
||||
alphaCaptchaGenerator,
|
||||
alphanumericCaptchaGenerator,
|
||||
numberCaptchaGenerator,
|
||||
} from "./text.ts";
|
||||
import { calculationCaptchaGenerator } from "./calculation.ts";
|
||||
import { imageCaptchaGenerator } from "./image.ts";
|
||||
import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
|
||||
import {
|
||||
honeypotCaptchaGenerator,
|
||||
notRobotCaptchaGenerator,
|
||||
timingCaptchaGenerator,
|
||||
} from "./invisible.ts";
|
||||
|
||||
export * from "./text.ts";
|
||||
export * from "./calculation.ts";
|
||||
@@ -13,7 +21,8 @@ export * from "./styles.ts";
|
||||
|
||||
export function defineCaptchaGenerator<T extends CaptchaChallengeGenerator>(generator: T): T {
|
||||
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
|
||||
if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
|
||||
if (typeof generator.generate !== "function")
|
||||
throw new TypeError("CAPTCHA generator requires generate()");
|
||||
return generator;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
return {
|
||||
type: this.type,
|
||||
presentation: "invisible",
|
||||
prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
prompt:
|
||||
this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
answer: JSON.stringify({ honeypot: "", timingToken }),
|
||||
answerKind: "invisible",
|
||||
inputMode: "none",
|
||||
|
||||
@@ -8,7 +8,11 @@ export interface RgbaImage {
|
||||
|
||||
export type Rgba = readonly [number, number, number, number?];
|
||||
|
||||
export function createImage(width: number, height: number, background: Rgba = [255, 255, 255, 255]): RgbaImage {
|
||||
export function createImage(
|
||||
width: number,
|
||||
height: number,
|
||||
background: Rgba = [255, 255, 255, 255],
|
||||
): RgbaImage {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
const alpha = background[3] ?? 255;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
@@ -33,13 +37,28 @@ export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): v
|
||||
image.data[index + 3] = 255;
|
||||
}
|
||||
|
||||
export function fillRect(image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba): void {
|
||||
export function fillRect(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: Rgba,
|
||||
): void {
|
||||
for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
|
||||
for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1): void {
|
||||
export function drawLine(
|
||||
image: RgbaImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
color: Rgba,
|
||||
thickness = 1,
|
||||
): void {
|
||||
let x = Math.round(x0);
|
||||
let y = Math.round(y0);
|
||||
const targetX = Math.round(x1);
|
||||
@@ -50,7 +69,14 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
|
||||
const sy = y < targetY ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
fillRect(image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color);
|
||||
fillRect(
|
||||
image,
|
||||
x - Math.floor(thickness / 2),
|
||||
y - Math.floor(thickness / 2),
|
||||
thickness,
|
||||
thickness,
|
||||
color,
|
||||
);
|
||||
if (x === targetX && y === targetY) break;
|
||||
const twice = 2 * error;
|
||||
if (twice >= dy) {
|
||||
@@ -64,7 +90,13 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
|
||||
}
|
||||
}
|
||||
|
||||
export function fillCircle(image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba): void {
|
||||
export function fillCircle(
|
||||
image: RgbaImage,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
color: Rgba,
|
||||
): void {
|
||||
const r2 = radius * radius;
|
||||
for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
|
||||
for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
|
||||
@@ -75,7 +107,11 @@ export function fillCircle(image: RgbaImage, centerX: number, centerY: number, r
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPolygon(x: number, y: number, points: readonly (readonly [number, number])[]): boolean {
|
||||
function pointInPolygon(
|
||||
x: number,
|
||||
y: number,
|
||||
points: readonly (readonly [number, number])[],
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i]![0];
|
||||
@@ -88,13 +124,18 @@ function pointInPolygon(x: number, y: number, points: readonly (readonly [number
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function fillPolygon(image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba): void {
|
||||
export function fillPolygon(
|
||||
image: RgbaImage,
|
||||
points: readonly (readonly [number, number])[],
|
||||
color: Rgba,
|
||||
): void {
|
||||
const minX = Math.floor(Math.min(...points.map((point) => point[0])));
|
||||
const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
|
||||
const minY = Math.floor(Math.min(...points.map((point) => point[1])));
|
||||
const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
|
||||
for (let x = minX; x <= maxX; x++)
|
||||
if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +175,15 @@ export function drawText(
|
||||
let cursor = options.x;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
|
||||
drawGlyph(image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear);
|
||||
drawGlyph(
|
||||
image,
|
||||
text[index]!,
|
||||
cursor + jitter.x,
|
||||
options.y + jitter.y,
|
||||
options.scale,
|
||||
options.color,
|
||||
jitter.shear,
|
||||
);
|
||||
cursor += options.scale * 5 + spacing;
|
||||
}
|
||||
}
|
||||
@@ -170,7 +219,12 @@ function adler32(bytes: Uint8Array): number {
|
||||
}
|
||||
|
||||
function u32(value: number): Uint8Array {
|
||||
return Uint8Array.of((value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255);
|
||||
return Uint8Array.of(
|
||||
(value >>> 24) & 255,
|
||||
(value >>> 16) & 255,
|
||||
(value >>> 8) & 255,
|
||||
value & 255,
|
||||
);
|
||||
}
|
||||
|
||||
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
||||
@@ -195,9 +249,15 @@ function deflateStored(data: Uint8Array): Uint8Array {
|
||||
const size = Math.min(65535, data.length - offset);
|
||||
const final = offset + size >= data.length;
|
||||
const length = size;
|
||||
const inverse = (~length) & 0xffff;
|
||||
const inverse = ~length & 0xffff;
|
||||
blocks.push(
|
||||
Uint8Array.of(final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255),
|
||||
Uint8Array.of(
|
||||
final ? 1 : 0,
|
||||
length & 255,
|
||||
(length >>> 8) & 255,
|
||||
inverse & 255,
|
||||
(inverse >>> 8) & 255,
|
||||
),
|
||||
data.slice(offset, offset + size),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaImageStyle,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaConcreteImageStyle, CaptchaImageStyle } from "../types.ts";
|
||||
|
||||
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
|
||||
"classic",
|
||||
@@ -52,9 +49,7 @@ export function normalizeCaptchaImageStyle(
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!STYLE_SET.has(normalized)) {
|
||||
throw new RangeError(
|
||||
`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
|
||||
);
|
||||
throw new RangeError(`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`);
|
||||
}
|
||||
return normalized as CaptchaImageStyle;
|
||||
}
|
||||
@@ -67,9 +62,7 @@ export function normalizeCaptchaImageStyleList(
|
||||
for (const entry of styleValues(value)) {
|
||||
if (entry === "random") continue;
|
||||
if (!CONCRETE_STYLE_SET.has(entry)) {
|
||||
throw new RangeError(
|
||||
`${name} contains an unknown image style: ${entry}`,
|
||||
);
|
||||
throw new RangeError(`${name} contains an unknown image style: ${entry}`);
|
||||
}
|
||||
const style = entry as CaptchaConcreteImageStyle;
|
||||
if (!styles.includes(style)) styles.push(style);
|
||||
@@ -95,17 +88,12 @@ export function resolveCaptchaImageStyle(
|
||||
options: ResolveCaptchaImageStyleOptions,
|
||||
): ResolvedCaptchaImageStyle {
|
||||
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
|
||||
const allowed = normalizeCaptchaImageStyleList(
|
||||
options.allowedStyles,
|
||||
"allowedStyles",
|
||||
);
|
||||
const allowed = normalizeCaptchaImageStyleList(options.allowedStyles, "allowedStyles");
|
||||
const excluded = new Set(
|
||||
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
|
||||
);
|
||||
|
||||
const source = allowed.length
|
||||
? allowed
|
||||
: [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const source = allowed.length ? allowed : [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const pool = source.filter((style) => !excluded.has(style));
|
||||
|
||||
if (!pool.length) {
|
||||
@@ -116,9 +104,7 @@ export function resolveCaptchaImageStyle(
|
||||
|
||||
if (!options.randomizeStyle && requested !== "random") {
|
||||
if (!pool.includes(requested)) {
|
||||
throw new RangeError(
|
||||
`imageStyle ${requested} is not available in the configured style pool`,
|
||||
);
|
||||
throw new RangeError(`imageStyle ${requested} is not available in the configured style pool`);
|
||||
}
|
||||
return { requested, resolved: requested, pool };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
|
||||
|
||||
function defaultLength(context: CaptchaGeneratorContext): number {
|
||||
return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
|
||||
return (
|
||||
context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6)
|
||||
);
|
||||
}
|
||||
|
||||
function charset(type: CaptchaChallengeType): string {
|
||||
@@ -20,11 +22,15 @@ function charset(type: CaptchaChallengeType): string {
|
||||
return ALPHANUMERIC;
|
||||
}
|
||||
|
||||
function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
function generateText(
|
||||
type: "number" | "alpha" | "alphanumeric",
|
||||
context: CaptchaGeneratorContext,
|
||||
): GeneratedCaptchaChallenge {
|
||||
const source = charset(type);
|
||||
const length = Math.max(3, Math.min(10, defaultLength(context)));
|
||||
let answer = "";
|
||||
for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
|
||||
for (let index = 0; index < length; index++)
|
||||
answer += source[context.randomInt(0, source.length - 1)];
|
||||
return {
|
||||
type,
|
||||
presentation: "visual",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaGeneratorContext,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaConcreteImageStyle, CaptchaGeneratorContext } from "../types.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawGlyph,
|
||||
@@ -56,7 +53,11 @@ function randomColor(
|
||||
];
|
||||
}
|
||||
|
||||
function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
|
||||
function rawPixel(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
): readonly [number, number, number, number] {
|
||||
const px = clamp(Math.round(x), 0, image.width - 1);
|
||||
const py = clamp(Math.round(y), 0, image.height - 1);
|
||||
const index = (py * image.width + px) * 4;
|
||||
@@ -98,11 +99,12 @@ function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
|
||||
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
|
||||
const glyphWidth = scale * 5;
|
||||
const normalSpacing = scale + 3;
|
||||
const spacing = style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const spacing =
|
||||
style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
|
||||
const textHeight = scale * 7;
|
||||
return {
|
||||
@@ -218,9 +220,8 @@ function drawCharacters(
|
||||
const y = layout.startY + context.randomInt(-jitterY, jitterY);
|
||||
const glyphShear = (context.randomFloat() - 0.5) * shear;
|
||||
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
|
||||
const color = style === "collision" || style === "cross-shadow" || style === "pixel"
|
||||
? accent
|
||||
: INK;
|
||||
const color =
|
||||
style === "collision" || style === "cross-shadow" || style === "pixel" ? accent : INK;
|
||||
|
||||
if (style === "cross-shadow") {
|
||||
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
|
||||
@@ -228,7 +229,15 @@ function drawCharacters(
|
||||
} else if (style === "collision") {
|
||||
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
|
||||
if (index > 0 && context.randomFloat() < 0.65) {
|
||||
drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
|
||||
drawGlyph(
|
||||
image,
|
||||
character,
|
||||
x + context.randomInt(-4, 1),
|
||||
y,
|
||||
layout.scale,
|
||||
[2, 6, 23, 60],
|
||||
-glyphShear,
|
||||
);
|
||||
}
|
||||
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
|
||||
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
|
||||
@@ -283,13 +292,25 @@ function shiftColumns(
|
||||
|
||||
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
|
||||
addCrossingLines(
|
||||
image,
|
||||
context,
|
||||
Math.round(3 + ratio * 5),
|
||||
Math.round(55 + ratio * 55),
|
||||
ratio > 0.7 ? 2 : 1,
|
||||
);
|
||||
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
|
||||
}
|
||||
|
||||
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
|
||||
addCrossingLines(
|
||||
image,
|
||||
context,
|
||||
Math.round(5 + ratio * 7),
|
||||
Math.round(60 + ratio * 60),
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
addDots(image, context, Math.round(120 + ratio * 320), 70);
|
||||
const bars = Math.round(2 + ratio * 5);
|
||||
for (let index = 0; index < bars; index++) {
|
||||
@@ -306,7 +327,15 @@ function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
|
||||
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
|
||||
addDots(
|
||||
image,
|
||||
context,
|
||||
Math.round(420 + ratio * 950),
|
||||
Math.round(85 + ratio * 75),
|
||||
120,
|
||||
245,
|
||||
ratio > 0.5 ? 2 : 1,
|
||||
);
|
||||
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
@@ -327,7 +356,14 @@ function applyCorrosion(
|
||||
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
|
||||
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
|
||||
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
|
||||
fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
|
||||
fillRect(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
size,
|
||||
context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210],
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
|
||||
@@ -341,12 +377,32 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
}));
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const current = nodes[index]!;
|
||||
const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
|
||||
if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
|
||||
const next =
|
||||
nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(
|
||||
image,
|
||||
current.x,
|
||||
current.y,
|
||||
next.x,
|
||||
next.y,
|
||||
[71, 85, 105, Math.round(65 + ratio * 65)],
|
||||
1,
|
||||
);
|
||||
if (index % 2 === 0)
|
||||
drawCircleOutline(
|
||||
image,
|
||||
current.x,
|
||||
current.y,
|
||||
context.randomInt(2, 5),
|
||||
[100, 116, 139, 70],
|
||||
1,
|
||||
);
|
||||
}
|
||||
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
|
||||
const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
|
||||
const anchorY = context.randomInt(
|
||||
Math.floor(image.height * 0.25),
|
||||
Math.floor(image.height * 0.75),
|
||||
);
|
||||
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
|
||||
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
|
||||
drawLine(
|
||||
@@ -365,8 +421,24 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const centerY = Math.floor(image.height / 2);
|
||||
drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(
|
||||
image,
|
||||
0,
|
||||
centerY - 6,
|
||||
image.width - 1,
|
||||
centerY + 6,
|
||||
[37, 99, 235, 75],
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
drawLine(
|
||||
image,
|
||||
0,
|
||||
centerY + 7,
|
||||
image.width - 1,
|
||||
centerY - 8,
|
||||
[220, 38, 38, 65],
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
|
||||
addDots(image, context, Math.round(120 + ratio * 300), 60);
|
||||
}
|
||||
@@ -399,11 +471,7 @@ function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
|
||||
}
|
||||
|
||||
function applyCut(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
function applyCut(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const cuts = Math.round(4 + ratio * 8);
|
||||
for (let index = 0; index < cuts; index++) {
|
||||
@@ -459,12 +527,32 @@ function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const rows = Math.round(4 + ratio * 5);
|
||||
for (let index = 0; index < rows; index++) {
|
||||
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
|
||||
drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
|
||||
drawDashedLine(
|
||||
image,
|
||||
0,
|
||||
y,
|
||||
image.width - 1,
|
||||
y + context.randomInt(-3, 3),
|
||||
[71, 85, 105, 75],
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const columns = Math.round(2 + ratio * 4);
|
||||
for (let index = 0; index < columns; index++) {
|
||||
const x = context.randomInt(10, image.width - 10);
|
||||
drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
|
||||
drawDashedLine(
|
||||
image,
|
||||
x,
|
||||
0,
|
||||
x + context.randomInt(-5, 5),
|
||||
image.height - 1,
|
||||
[100, 116, 139, 65],
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
}
|
||||
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
@@ -479,7 +567,15 @@ function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void
|
||||
const spacing = Math.round(10 - ratio * 4);
|
||||
const offset = context.randomInt(-image.height, image.width);
|
||||
for (let x = offset; x < image.width + image.height; x += spacing) {
|
||||
drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
0,
|
||||
x - image.height,
|
||||
image.height - 1,
|
||||
[71, 85, 105, Math.round(45 + ratio * 45)],
|
||||
ratio > 0.7 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
|
||||
@@ -595,16 +691,20 @@ function applyBrokenLines(
|
||||
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
|
||||
const x = context.randomInt(0, image.width - 12);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
x + context.randomInt(4, 18),
|
||||
y + context.randomInt(-2, 2),
|
||||
randomColor(context, 45, 175, 75),
|
||||
1,
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(70 + ratio * 180), 60);
|
||||
}
|
||||
|
||||
function applyStyle(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
function applyStyle(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
|
||||
switch (context.imageStyle) {
|
||||
case "collision":
|
||||
applyCollision(image, context);
|
||||
@@ -667,10 +767,7 @@ function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
|
||||
return LIGHT_BACKGROUND;
|
||||
}
|
||||
|
||||
export function renderTextChallenge(
|
||||
text: string,
|
||||
context: CaptchaGeneratorContext,
|
||||
): string {
|
||||
export function renderTextChallenge(text: string, context: CaptchaGeneratorContext): string {
|
||||
const layout = layoutFor(text, context.imageStyle);
|
||||
const image = createImage(300, 104, backgroundFor(context.imageStyle));
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function defaultRandomBytes(length: number): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1) throw new RangeError("random byte length must be positive");
|
||||
if (!Number.isInteger(length) || length < 1)
|
||||
throw new RangeError("random byte length must be positive");
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
|
||||
+160
-50
@@ -1,8 +1,19 @@
|
||||
import { defaultCaptchaGenerators } from "./challenges/index.ts";
|
||||
import { resolveCaptchaImageStyle } from "./challenges/styles.ts";
|
||||
import { AssetAudioRenderer } from "./audio/renderer.ts";
|
||||
import { bindingHash, constantTimeEqual, defaultRandomBytes, hmacSha256, randomId, sha256 } from "./crypto.ts";
|
||||
import { normalizeSelections, normalizeTextAnswer, normalizedSubmittedAnswer } from "./normalize.ts";
|
||||
import {
|
||||
bindingHash,
|
||||
constantTimeEqual,
|
||||
defaultRandomBytes,
|
||||
hmacSha256,
|
||||
randomId,
|
||||
sha256,
|
||||
} from "./crypto.ts";
|
||||
import {
|
||||
normalizeSelections,
|
||||
normalizeTextAnswer,
|
||||
normalizedSubmittedAnswer,
|
||||
} from "./normalize.ts";
|
||||
import { MemoryCaptchaStore } from "./stores/memory.ts";
|
||||
import type {
|
||||
CaptchaBinding,
|
||||
@@ -40,13 +51,16 @@ function failure(
|
||||
function assertAction(action: string): string {
|
||||
const value = action.trim();
|
||||
if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) {
|
||||
throw new TypeError("CAPTCHA action must be a non-empty stable identifier up to 128 characters");
|
||||
throw new TypeError(
|
||||
"CAPTCHA action must be a non-empty stable identifier up to 128 characters",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
|
||||
if (!Number.isInteger(value) || value < 1)
|
||||
throw new RangeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -54,7 +68,8 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
|
||||
const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50;
|
||||
if (value === undefined) return fallback;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) throw new RangeError("disturbance must be a finite number between 25 and 75");
|
||||
if (!Number.isFinite(numeric))
|
||||
throw new RangeError("disturbance must be a finite number between 25 and 75");
|
||||
const normalized = Math.round(numeric);
|
||||
if (normalized < 25 || normalized > 75) {
|
||||
throw new RangeError("disturbance must be between 25 and 75");
|
||||
@@ -62,8 +77,13 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function randomInteger(randomBytes: (length: number) => Uint8Array, min: number, max: number): number {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new RangeError("invalid random range");
|
||||
function randomInteger(
|
||||
randomBytes: (length: number) => Uint8Array,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min)
|
||||
throw new RangeError("invalid random range");
|
||||
const span = max - min + 1;
|
||||
if (span === 1) return min;
|
||||
const limit = Math.floor(0x1_0000_0000 / span) * span;
|
||||
@@ -74,10 +94,13 @@ function randomInteger(randomBytes: (length: number) => Uint8Array, min: number,
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesHash(expected: string | undefined, raw: string | undefined): Promise<boolean> {
|
||||
async function matchesHash(
|
||||
expected: string | undefined,
|
||||
raw: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!expected) return true;
|
||||
if (!raw) return false;
|
||||
return constantTimeEqual(expected, await bindingHash(raw) ?? "");
|
||||
return constantTimeEqual(expected, (await bindingHash(raw)) ?? "");
|
||||
}
|
||||
|
||||
export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
@@ -106,9 +129,18 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
this.store = options.store ?? new MemoryCaptchaStore();
|
||||
this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer();
|
||||
this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`;
|
||||
this.challengeTtlMs = assertPositiveInteger(options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS, "challengeTtlMs");
|
||||
this.responseTokenTtlMs = assertPositiveInteger(options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, "responseTokenTtlMs");
|
||||
this.maxAttempts = assertPositiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, "maxAttempts");
|
||||
this.challengeTtlMs = assertPositiveInteger(
|
||||
options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS,
|
||||
"challengeTtlMs",
|
||||
);
|
||||
this.responseTokenTtlMs = assertPositiveInteger(
|
||||
options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS,
|
||||
"responseTokenTtlMs",
|
||||
);
|
||||
this.maxAttempts = assertPositiveInteger(
|
||||
options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
"maxAttempts",
|
||||
);
|
||||
this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS);
|
||||
this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD;
|
||||
this.defaultType = options.defaultType ?? "alphanumeric";
|
||||
@@ -116,19 +148,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
this.bindIp = options.bindIp ?? false;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator);
|
||||
if (!this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators())
|
||||
this.generators.set(generator.type, generator);
|
||||
if (!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 requestedPresentation = options.presentation ?? (
|
||||
requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
const requestedPresentation =
|
||||
options.presentation ??
|
||||
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
? "invisible"
|
||||
: "visual"
|
||||
);
|
||||
const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
|
||||
: "visual");
|
||||
const actualType =
|
||||
requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
|
||||
const generator = this.generators.get(actualType);
|
||||
if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`);
|
||||
const now = this.now();
|
||||
@@ -161,19 +196,25 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
const id = randomId(this.randomBytes, 24);
|
||||
const answerSalt = randomId(this.randomBytes, 16);
|
||||
const caseSensitive = options.caseSensitive ?? false;
|
||||
const normalizedAnswer = generated.answerKind === "selections"
|
||||
? normalizeSelections(generated.answer.split(","))
|
||||
: generated.answerKind === "text"
|
||||
? normalizeTextAnswer(generated.answer, caseSensitive)
|
||||
: generated.answer;
|
||||
const normalizedAnswer =
|
||||
generated.answerKind === "selections"
|
||||
? normalizeSelections(generated.answer.split(","))
|
||||
: generated.answerKind === "text"
|
||||
? normalizeTextAnswer(generated.answer, caseSensitive)
|
||||
: generated.answer;
|
||||
const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`);
|
||||
const expiresAt = now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
|
||||
const maxAttempts = assertPositiveInteger(options.maxAttempts ?? this.maxAttempts, "maxAttempts");
|
||||
const expiresAt =
|
||||
now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
|
||||
const maxAttempts = assertPositiveInteger(
|
||||
options.maxAttempts ?? this.maxAttempts,
|
||||
"maxAttempts",
|
||||
);
|
||||
const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined;
|
||||
const responseField = options.responseField ?? this.responseField;
|
||||
const presentation = requestedPresentation === "audio" && generated.audioSequence?.length
|
||||
? "audio"
|
||||
: generated.presentation;
|
||||
const presentation =
|
||||
requestedPresentation === "audio" && generated.audioSequence?.length
|
||||
? "audio"
|
||||
: generated.presentation;
|
||||
|
||||
const publicChallenge: CaptchaChallenge = {
|
||||
id,
|
||||
@@ -190,7 +231,9 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
items: presentation === "audio" ? undefined : generated.items,
|
||||
minSelections: generated.minSelections,
|
||||
maxSelections: generated.maxSelections,
|
||||
audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined,
|
||||
audioUrl: audioKey
|
||||
? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}`
|
||||
: undefined,
|
||||
refreshUrl: `${this.basePath}/challenge`,
|
||||
verifyUrl: `${this.basePath}/verify`,
|
||||
honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined,
|
||||
@@ -202,9 +245,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
requestedImageStyle: context.requestedImageStyle,
|
||||
imageStylePool: [...context.imageStylePool],
|
||||
locale: context.locale,
|
||||
...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}),
|
||||
...(generated.answerKind === "invisible"
|
||||
? { minCompletionMs: context.minCompletionMs }
|
||||
: {}),
|
||||
...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
|
||||
...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}),
|
||||
...(requestedType === "image" && actualType !== requestedType
|
||||
? { alternativeFor: requestedType }
|
||||
: {}),
|
||||
...options.metadata,
|
||||
},
|
||||
};
|
||||
@@ -248,14 +295,23 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id");
|
||||
const now = this.now();
|
||||
const record = await this.store.getChallenge(input.challengeId);
|
||||
if (!record) return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
|
||||
if (record.expiresAt <= now) return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
|
||||
if (record.consumedAt) return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
|
||||
if (!record)
|
||||
return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
|
||||
if (record.expiresAt <= now)
|
||||
return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
|
||||
if (record.consumedAt)
|
||||
return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
|
||||
const bindingFailure = await this.checkChallengeBinding(record, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
|
||||
const attempted = await this.store.incrementAttempts(record.id, now);
|
||||
if (!attempted) return failure(action, "already-used", "The CAPTCHA challenge is no longer available", record.id);
|
||||
if (!attempted)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA challenge is no longer available",
|
||||
record.id,
|
||||
);
|
||||
if (attempted.attempts > attempted.maxAttempts) {
|
||||
await this.store.consumeChallenge(attempted.id, now);
|
||||
return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id);
|
||||
@@ -269,7 +325,10 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
}
|
||||
|
||||
const submitted = normalizedSubmittedAnswer(attempted, input);
|
||||
const digest = await hmacSha256(this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`);
|
||||
const digest = await hmacSha256(
|
||||
this.secret,
|
||||
`${attempted.id}:${attempted.answerSalt}:${submitted}`,
|
||||
);
|
||||
if (!constantTimeEqual(attempted.answerDigest, digest)) {
|
||||
const exhausted = attempted.attempts >= attempted.maxAttempts;
|
||||
if (exhausted) await this.store.consumeChallenge(attempted.id, now);
|
||||
@@ -282,7 +341,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
}
|
||||
|
||||
const consumed = await this.store.consumeChallenge(attempted.id, now);
|
||||
if (!consumed) return failure(action, "already-used", "The CAPTCHA challenge was already used", attempted.id);
|
||||
if (!consumed)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA challenge was already used",
|
||||
attempted.id,
|
||||
);
|
||||
const plainToken = randomId(this.randomBytes, 32);
|
||||
const tokenHash = await sha256(plainToken);
|
||||
const expiresAt = now + this.responseTokenTtlMs;
|
||||
@@ -319,13 +384,33 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
const tokenHash = await sha256(token);
|
||||
const existing = await this.store.getToken(tokenHash);
|
||||
if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token");
|
||||
if (existing.expiresAt <= now) return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
|
||||
if (existing.consumedAt) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
if (existing.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", existing.challengeId);
|
||||
if (existing.expiresAt <= now)
|
||||
return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
|
||||
if (existing.consumedAt)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA response token was already used",
|
||||
existing.challengeId,
|
||||
);
|
||||
if (existing.action !== action)
|
||||
return failure(
|
||||
action,
|
||||
"action-mismatch",
|
||||
"The CAPTCHA action does not match",
|
||||
existing.challengeId,
|
||||
);
|
||||
const bindingFailure = await this.checkTokenBinding(existing, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
const record = input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
|
||||
if (!record) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
const record =
|
||||
input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
|
||||
if (!record)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA response token was already used",
|
||||
existing.challengeId,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
provider: "self-hosted",
|
||||
@@ -338,13 +423,17 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
};
|
||||
}
|
||||
|
||||
async renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
|
||||
async renderAudio(
|
||||
challengeId: string,
|
||||
key: string,
|
||||
): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
|
||||
const record = await this.store.getChallenge(challengeId);
|
||||
if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined;
|
||||
const expectedKey = String(record.metadata.audioKey ?? "");
|
||||
if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined;
|
||||
const sequence = record.metadata.audioSequence;
|
||||
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string")) return undefined;
|
||||
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string"))
|
||||
return undefined;
|
||||
const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en"));
|
||||
return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" };
|
||||
}
|
||||
@@ -358,7 +447,8 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
input: CaptchaBinding,
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (record.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
|
||||
if (record.action !== action)
|
||||
return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id);
|
||||
}
|
||||
@@ -366,7 +456,12 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.id);
|
||||
return failure(
|
||||
action,
|
||||
"ip-mismatch",
|
||||
"The CAPTCHA network binding does not match",
|
||||
record.id,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -377,13 +472,28 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"hostname-mismatch",
|
||||
"The CAPTCHA hostname does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
if (!(await matchesHash(record.sessionHash, input.sessionId))) {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"session-mismatch",
|
||||
"The CAPTCHA session does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"ip-mismatch",
|
||||
"The CAPTCHA network binding does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ function json(body: unknown, status = 200, headers: HeadersInit = {}): Response
|
||||
|
||||
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) return (await request.json()) as Record<string, unknown>;
|
||||
if (contentType.includes("application/json"))
|
||||
return (await request.json()) as Record<string, unknown>;
|
||||
if (contentType.includes("form")) {
|
||||
const form = await request.formData();
|
||||
const payload: Record<string, unknown> = {};
|
||||
@@ -88,15 +89,22 @@ export function createCaptchaHttpHandlers(
|
||||
counters.set(key, counter);
|
||||
}
|
||||
counter.count += 1;
|
||||
return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)) };
|
||||
return {
|
||||
allowed: counter.count <= maximum,
|
||||
retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)),
|
||||
};
|
||||
};
|
||||
|
||||
const create = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
if (request.method !== "POST")
|
||||
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
if (!limit.allowed)
|
||||
return json({ success: false, code: "rate-limited" }, 429, {
|
||||
"retry-after": String(limit.retryAfter),
|
||||
});
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const challenge = await engine.create({
|
||||
@@ -118,10 +126,14 @@ export function createCaptchaHttpHandlers(
|
||||
|
||||
const verify = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
if (request.method !== "POST")
|
||||
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
if (!limit.allowed)
|
||||
return json({ success: false, code: "rate-limited" }, 429, {
|
||||
"retry-after": String(limit.retryAfter),
|
||||
});
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const result = await engine.verify({
|
||||
@@ -143,7 +155,8 @@ export function createCaptchaHttpHandlers(
|
||||
|
||||
const audio = async (request: Request): Promise<Response> => {
|
||||
if (!originAllowed(request)) return new Response("Forbidden", { status: 403 });
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
|
||||
if (request.method !== "GET" && request.method !== "HEAD")
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
const url = new URL(request.url);
|
||||
const prefix = `${engine.basePath}/audio/`;
|
||||
const id = decodeURIComponent(url.pathname.slice(prefix.length));
|
||||
|
||||
@@ -76,7 +76,8 @@ export function captchaGuard(options: CaptchaGuardOptions) {
|
||||
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
|
||||
const result = await verifyRequest(ctx, options);
|
||||
ctx.locals.captcha = result;
|
||||
if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
if (!result.success)
|
||||
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export function captchaPageGate(options: CaptchaPageGateOptions) {
|
||||
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
|
||||
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
|
||||
|
||||
const signals = await options.signals?.(ctx) ?? {};
|
||||
const signals = (await options.signals?.(ctx)) ?? {};
|
||||
const decision = shouldRequireCaptcha(action, policy, signals);
|
||||
ctx.locals.captchaRisk = decision;
|
||||
if (!decision.challenge) return next();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user