release: WRNexusJS 0.5.10

This commit is contained in:
2026-07-30 13:36:29 +05:30
parent 8fc6f15402
commit d1b0c55b53
159 changed files with 7509 additions and 604 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
@@ -36,13 +36,9 @@ component AuthenticatorSetup {
novalidate
class='mt-5 space-y-4 {credentialId == "" ? "" : "hidden"}'
>
<div class="space-y-1.5">
<label for="auth-authenticator-label" class="block text-sm font-medium">Authenticator name</label>
<input id="auth-authenticator-label" name="label" value="Authenticator" autocomplete="off" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" />
<p data-error="label" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input id="auth-authenticator-label" name="label" label="Authenticator name" value="Authenticator" autocomplete="off" icon="icon-[lucide--smartphone]" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{setupLabel}</button>
<Button type="submit" label="{setupLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<div data-auth-authenticator-details class='{credentialId == "" ? "hidden" : ""}'>
@@ -53,15 +49,11 @@ component AuthenticatorSetup {
</div>
<form method="post" action='{action}' data-schema='{schema}' data-auth-authenticator-confirm novalidate class="mt-5 space-y-4">
<input type="hidden" name="credentialId" value='{credentialId}' />
<div class="space-y-1.5">
<label for="auth-authenticator-code" class="block text-sm font-medium">Authenticator code</label>
<input id="auth-authenticator-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<PinInput label="Authenticator code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-error="credentialId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
</div>
</section>
+4 -6
View File
@@ -5,6 +5,7 @@ component ForgotPassword {
title = "Recover your account"
description = "Enter your email, phone, or username."
identifierLabel = "Account identifier"
identifierPlaceholder = "Enter your registered email, mobile number, or username"
submitLabel = "Send recovery link"
successMessage = "If an account matches, a recovery link has been sent."
signInHref = "/sign-in"
@@ -17,14 +18,11 @@ component ForgotPassword {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5">
<label for="auth-recovery-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-recovery-identifier" name="identifier" autocomplete="username" aria-describedby="auth-recovery-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
<p id="auth-recovery-identifier-error" data-error="identifier" role="alert" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input id="auth-recovery-identifier" name="identifier" label="{identifierLabel}" autocomplete="username" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
<slot></slot>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{signInHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Back to sign in</a>
</section>
@@ -22,12 +22,12 @@ component InvitationAccept {
<div class="mt-5 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4 text-sm"><p class="m-0 font-semibold">{organization}</p><p class="mb-0 mt-1 text-[var(--wire-color-muted)]">Invited by {inviter}</p></div>
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-5 space-y-4">
<input type="hidden" name="token" value='{token}' />
<div class="space-y-1.5"><label for="auth-invitation-name" class="block text-sm font-medium">Display name</label><input id="auth-invitation-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<div class="space-y-1.5"><label for="auth-invitation-password" class="block text-sm font-medium">Create password</label><input id="auth-invitation-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<Input id="auth-invitation-name" name="displayName" label="Display name" autocomplete="name" placeholder="Enter your display name" icon="icon-[lucide--user]" color="{color}" size="{size}" />
<StrongPassword label="Create password" name="password" autocomplete="new-password" minLength="12" requireLowercase="true" requireUppercase="true" requireNumber="true" requireSpecialCharacter="true" color="{color}" size="{size}" />
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
</section>
}
+2 -2
View File
@@ -18,10 +18,10 @@ component MagicLinkSignIn {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mb-0 mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-magic-link-identifier" class="block text-sm font-medium">{emailLabel}</label><input id="auth-magic-link-identifier" name="identifier" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" /><p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<Input id="auth-magic-link-identifier" name="identifier" type="email" label="{emailLabel}" autocomplete="email" placeholder="Enter your email address" icon="icon-[lucide--mail]" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{signInHref}' class="mt-4 inline-flex text-sm font-medium text-[var(--wire-color-primary)] hover:underline">Return to password sign-in</a>
</section>
+5 -13
View File
@@ -41,15 +41,11 @@ component OtpSignIn {
class='mt-6 space-y-4 {challengeId == "" ? "" : "hidden"}'
>
<input type="hidden" name="method" value='{method}' />
<div class="space-y-1.5">
<label for="auth-otp-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-otp-identifier" name="identifier" type="text" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input id="auth-otp-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username" placeholder="Enter your email address or phone number" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{requestLabel}</button>
<Button type="submit" label="{requestLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<form
@@ -62,15 +58,11 @@ component OtpSignIn {
>
<input type="hidden" name="challengeId" value='{challengeId}' />
<input type="hidden" name="returnTo" value='{returnTo}' />
<div class="space-y-1.5">
<label for="auth-otp-code" class="block text-sm font-medium">One-time code</label>
<input id="auth-otp-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em] outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<PinInput label="One-time code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{verifyLabel}</button>
<button type="button" data-auth-otp-back class="w-full text-sm text-[var(--wire-color-primary)] hover:underline">Use a different account</button>
<Button type="submit" label="{verifyLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
<Button type="button" data-auth-otp-back label="Use a different account" variant="ghost" color="{color}" size="{size}" fullWidth="true" />
</form>
</section>
}
+2 -7
View File
@@ -18,16 +18,11 @@ component ResetPassword {
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-6 space-y-4">
<input type="hidden" name="token" value='{token}' />
<div class="space-y-1.5">
<label for="auth-reset-password" class="block text-sm font-medium">New password</label>
<input id="auth-reset-password" name="password" type="password" autocomplete="new-password" aria-describedby="auth-reset-password-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
<p id="auth-reset-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<StrongPassword label="New password" name="password" autocomplete="new-password" minLength="12" requireLowercase="true" requireUppercase="true" requireNumber="true" requireSpecialCharacter="true" color="{color}" size="{size}" />
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
</section>
}
+15 -10
View File
@@ -8,7 +8,9 @@ component SignIn {
title = "Welcome back"
description = "Sign in to continue to your account."
identifierLabel = "Email, phone, or username"
identifierPlaceholder = "Enter your email, mobile number, or username"
passwordLabel = "Password"
passwordPlaceholder = "Enter your password"
submitLabel = "Sign in"
forgotHref = "/recover"
signUpHref = "/sign-up"
@@ -29,22 +31,25 @@ component SignIn {
<input type="hidden" name="returnTo" value='{returnTo}' />
<input type="hidden" name="deviceFingerprint" value="" />
<input type="hidden" name="deviceName" value="" />
<Input id="auth-sign-in-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username webauthn" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
<div class="space-y-1.5">
<label for="auth-sign-in-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-sign-in-identifier" name="identifier" type="text" autocomplete="username webauthn" aria-describedby="auth-sign-in-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
<p id="auth-sign-in-identifier-error" data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-3 text-sm font-medium"><label for="auth-sign-in-password">{passwordLabel}</label><a href='{forgotHref}' class="text-xs text-[var(--wire-color-primary)] hover:underline">Forgot password?</a></div>
<input id="auth-sign-in-password" name="password" type="password" autocomplete="current-password" aria-describedby="auth-sign-in-password-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
<p id="auth-sign-in-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<div class="flex justify-end"><a href='{forgotHref}' class="text-xs text-[var(--wire-color-primary)] hover:underline">Forgot password?</a></div>
<TogglePassword
label="{passwordLabel}"
name="password"
placeholder="{passwordPlaceholder}"
autocomplete="current-password"
pattern=""
color="{color}"
size="{size}"
/>
</div>
{#if showRemember}
<label class="flex items-center gap-2 text-sm text-[var(--wire-color-muted)]"><input name="rememberDevice" type="checkbox" class="size-4 rounded border-[var(--wire-color-border)]" />Trust this device</label>
<Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" />
{/if}
<slot></slot>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="inline-flex h-11 w-full items-center justify-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
{#if showPasskey}
<div class="my-4 flex items-center gap-3 text-xs text-[var(--wire-color-muted)]"><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span><span>or</span><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span></div>
+147 -42
View File
@@ -7,6 +7,13 @@ component SignUp {
description = "Use a strong password and verify your contact details."
submitLabel = "Create account"
signInHref = "/sign-in"
termsHref = "/legal/terms"
privacyHref = "/legal/privacy-policy"
namePlaceholder = "Enter your full name"
emailPlaceholder = "Enter your email address"
phonePlaceholder = "Enter your mobile number"
usernamePlaceholder = "Choose a username"
passwordPlaceholder = "Create a strong password"
showPhone = true
showUsername = true
requireConsent = true
@@ -15,10 +22,24 @@ component SignUp {
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<header class="mb-6 space-y-1.5">
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p>
<section
{...attrs}
data-wrnexus-runtime="auth"
class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'
>
<header
class="mb-6 space-y-1.5"
>
<h1
class="m-0 text-2xl font-semibold"
>
{title}
</h1>
<p
class="m-0 text-sm text-[var(--wire-color-muted)]"
>
{description}
</p>
</header>
<form
@@ -29,62 +50,146 @@ component SignUp {
novalidate
class="grid gap-4 sm:grid-cols-2"
>
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-name" class="text-sm font-medium">Full name</label>
<input id="auth-sign-up-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-name"
name="displayName"
label="Full name"
autocomplete="name"
placeholder="{namePlaceholder}"
icon="icon-[lucide--user]"
color="{color}"
size="{size}"
class="sm:col-span-2"
/>
<div class="space-y-1.5">
<label for="auth-sign-up-email" class="text-sm font-medium">Email</label>
<input id="auth-sign-up-email" name="email" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="email" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-email"
name="email"
type="email"
label="Email"
autocomplete="email"
placeholder="{emailPlaceholder}"
icon="icon-[lucide--mail]"
color="{color}"
size="{size}"
/>
{#if showPhone}
<div class="space-y-1.5">
<label for="auth-sign-up-phone" class="text-sm font-medium">Phone</label>
<input id="auth-sign-up-phone" name="phone" type="tel" autocomplete="tel" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="phone" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-phone"
name="phone"
type="tel"
label="Phone"
autocomplete="tel"
placeholder="{phonePlaceholder}"
icon="icon-[lucide--phone]"
color="{color}"
size="{size}"
/>
{/if}
{#if showUsername}
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-username" class="text-sm font-medium">Username</label>
<input id="auth-sign-up-username" name="username" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="username" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Input
id="auth-sign-up-username"
name="username"
label="Username"
autocomplete="username"
placeholder="{usernamePlaceholder}"
icon="icon-[lucide--circle-user-round]"
color="{color}"
size="{size}"
class="sm:col-span-2"
/>
{/if}
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-password" class="text-sm font-medium">Password</label>
<input id="auth-sign-up-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
<p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<div
class="space-y-1.5 sm:col-span-2"
>
<StrongPassword
label="Password"
name="password"
placeholder="{passwordPlaceholder}"
autocomplete="new-password"
minLength="12"
requireLowercase="true"
requireUppercase="true"
requireNumber="true"
requireSpecialCharacter="true"
presentation="inline"
color="{color}"
size="{size}"
/>
</div>
{#if requireConsent}
<div class="space-y-1 sm:col-span-2">
<label class="flex items-start gap-2 text-sm text-[var(--wire-color-muted)]">
<input name="consent" type="checkbox" class="mt-0.5 size-4" />
<span>I agree to the terms and privacy policy.</span>
</label>
<p data-error="consent" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Checkbox
id="auth-sign-up-consent"
name="consent"
label="I agree to the"
color="{color}"
size="{size}"
class="sm:col-span-2"
>
<span
class="flex items-center"
>
<a
href="{termsHref}"
>
terms
</a>
and
<a
href="{privacyHref}"
>
privacy policy
</a>.
</span>
</Checkbox>
{:else}
<input type="hidden" name="consent" value="true" />
<input
type="hidden"
name="consent"
value="true"
/>
{/if}
<div class="sm:col-span-2"><slot></slot></div>
<div
class="sm:col-span-2"
>
<slot></slot>
</div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)] sm:col-span-2"></p>
<p data-success="Account created successfully." role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)] sm:col-span-2"></p>
<p
data-error="_form"
role="alert"
class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)] sm:col-span-2"
>
</p>
<p
data-success="Account created successfully."
role="status"
hidden
class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)] sm:col-span-2"
>
</p>
<button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white sm:col-span-2 disabled:cursor-not-allowed disabled:opacity-60">{submitLabel}</button>
<Button
type="submit"
label="{submitLabel}"
variant="solid"
color="{color}"
size="{size}"
fullWidth="true"
class="sm:col-span-2"
/>
</form>
<p class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]">Already have an account? <a href='{signInHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Sign in</a></p>
<p
class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]"
>
Already have an account? <a href='{signInHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Sign in</a>
</p>
</section>
}
}
@@ -15,6 +15,12 @@ component TwoFactorChallenge {
size = "md"
class = ""
}
state methodOptions = [
{"label":"Authenticator app","value":"totp"},
{"label":"Recovery code","value":"recovery-code"},
{"label":"Email code","value":"email-otp"},
{"label":"SMS code","value":"sms-otp"}
]
view {
<section
{...attrs}
@@ -31,26 +37,13 @@ component TwoFactorChallenge {
<input type="hidden" name="mfaToken" value='{mfaToken}' />
<input type="hidden" name="challengeId" value='{challengeId}' />
<input type="hidden" name="returnTo" value='{returnTo}' />
<div class="space-y-1.5">
<label for="auth-mfa-method" class="block text-sm font-medium">Verification method</label>
<select id="auth-mfa-method" name="method" data-auth-mfa-method class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3">
<option value="totp">Authenticator app</option>
<option value="recovery-code">Recovery code</option>
<option value="email-otp">Email code</option>
<option value="sms-otp">SMS code</option>
</select>
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<div class="space-y-1.5">
<label for="auth-mfa-code" class="block text-sm font-medium">Verification code</label>
<input id="auth-mfa-code" name="code" inputmode="numeric" autocomplete="one-time-code" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<Select id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" color="{color}" size="{size}" />
<PinInput label="Verification code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wire-color-primary)]"></p>
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Account recovery options</a>
</section>
+3 -3
View File
@@ -20,17 +20,17 @@ component VerifyEmail {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-email-token" class="block text-sm font-medium">Verification token</label><input id="auth-email-token" name="token" value='{token}' autocomplete="one-time-code" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<Input id="auth-email-token" name="token" label="Verification token" value="{token}" autocomplete="one-time-code" placeholder="Enter your verification token" icon="icon-[lucide--badge-check]" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
<input type="hidden" name="type" value='{kind}' />
<input type="hidden" name="identifier" value='{identifier}' />
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
<Button type="submit" label="Send a new code" variant="ghost" color="{color}" size="sm" />
</form>
</section>
}
+3 -3
View File
@@ -20,17 +20,17 @@ component VerifyPhone {
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-phone-token" class="block text-sm font-medium">Verification token</label><input id="auth-phone-token" name="token" value='{token}' autocomplete="one-time-code" inputmode="numeric" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<PinInput label="Verification token" name="token" value="{token}" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
<input type="hidden" name="type" value='{kind}' />
<input type="hidden" name="identifier" value='{identifier}' />
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
<Button type="submit" label="Send a new code" variant="ghost" color="{color}" size="sm" />
</form>
</section>
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.5.1",
"version": "0.5.10",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
@@ -36,7 +36,7 @@
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck \u0026\u0026 bun run test"
"check": "bun run typecheck && bun run test"
},
"dependencies": {
"@wrnexus/authz": "workspace:*",
@@ -47,6 +47,7 @@
"@wrnexus/oauth": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/queue": "workspace:*",
"@wrnexus/ui": "workspace:*",
"@wrnexus/validation": "workspace:*",
"@wrnexus/db": "workspace:*"
},
+21 -1
View File
@@ -9,7 +9,11 @@ import {
getAuthUser,
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "../types.ts";
import type {
AuthSessionVerificationHandler,
AuthSignedInHandler,
AuthSignedOutHandler,
} from "../types.ts";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -53,6 +57,7 @@ export interface AuthHttpOptions {
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
onSessionVerification?: AuthSessionVerificationHandler;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
@@ -83,6 +88,21 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
}
return {
async verifySession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (options.onSessionVerification) {
return options.onSessionVerification(ctx, user);
}
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
if ((ctx.req.headers.get("accept") ?? "").includes("application/json")) {
return json({ ok: true, user });
}
return new Response(null, {
status: 204,
headers: { "cache-control": "no-store" },
});
},
async register(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.register, ctx.req);
if (!validation.ok) return validation.response;
+9 -2
View File
@@ -12,9 +12,16 @@ function wantsJson(ctx: Context): boolean {
return accept.includes("application/json") && !accept.includes("text/html");
}
export function authSession(engine: AuthEngine): Middleware {
export interface AuthSessionOptions {
/** Optional shared SSO cookie containing an AuthEngine session id. */
cookieName?: string;
}
export function authSession(engine: AuthEngine, options: AuthSessionOptions = {}): Middleware {
return async (ctx, next) => {
const sessionId = ctx.session.get<string>(AUTH_SESSION_KEY);
const sessionId =
(options.cookieName ? ctx.cookies.get(options.cookieName) : undefined) ??
ctx.session.get<string>(AUTH_SESSION_KEY);
if (!sessionId) {
ctx.user = null;
ctx.locals.authUser = null;
+27 -5
View File
@@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "./types.ts";
import type {
AuthSessionVerificationHandler,
AuthSignedInHandler,
AuthSignedOutHandler,
} from "./types.ts";
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
import {
clearDefaultAuthEngine,
@@ -52,6 +56,10 @@ export interface AuthConfig {
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
/** Shared SSO cookie whose value is an AuthEngine session id. */
sessionCookieName?: string;
/** Customize the package forward-auth verification response. */
onSessionVerification?: AuthSessionVerificationHandler;
}
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
@@ -88,14 +96,22 @@ interface ResolvedAuthConfig {
passkey?: AuthPasskeyHttpOptions;
onSignedIn?: AuthSignedInHandler;
onSignedOut?: AuthSignedOutHandler;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const moduleRoot = dirname(fileURLToPath(import.meta.url));
const packageRoot = dirname(moduleRoot);
const compiledPackage = moduleRoot === join(packageRoot, "dist");
const clientRuntime = join(packageRoot, "assets", "client", "auth.js");
const migrationsFile = join(packageRoot, "migrations", "001_auth.sql");
const otpPurposeMigrationFile = join(packageRoot, "migrations", "002_auth_otp_purpose.sql");
const apiRoutesDir = join(packageRoot, "src", "routes", "api");
const middlewareFile = join(packageRoot, "src", "routes", "middleware.ts");
const apiRoutesDir = compiledPackage
? join(moduleRoot, "routes", "api")
: join(packageRoot, "src", "routes", "api");
const middlewareFile = compiledPackage
? join(moduleRoot, "routes", "middleware.js")
: join(packageRoot, "src", "routes", "middleware.ts");
const resolvedConfigKey = "@wrnexus/auth:resolved-config";
export function authComponentsDir(): string {
@@ -104,7 +120,7 @@ export function authComponentsDir(): string {
function authApiRouteEntry(path: string): string {
const routeName = path.replace(/^\/api\/auth\/?/, "").replace(/\//g, "-") || "index";
return join(apiRoutesDir, routeName + ".ts");
return join(apiRoutesDir, routeName + (compiledPackage ? ".js" : ".ts"));
}
function resolveConfig(
@@ -142,6 +158,8 @@ function resolveConfig(
passkey: raw.passkey,
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
sessionCookieName: raw.sessionCookieName,
onSessionVerification: raw.onSessionVerification,
};
}
@@ -377,6 +395,10 @@ export function authPlugin(options: AuthPluginOptions = {}) {
onSignedIn: value.onSignedIn,
onSignedOut: value.onSignedOut,
sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification,
});
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
+1
View File
@@ -62,6 +62,7 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
passkey: routeOptions.passkey,
onSignedIn: routeOptions.onSignedIn,
onSignedOut: routeOptions.onSignedOut,
onSessionVerification: routeOptions.onSessionVerification,
});
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifySession", ctx);
}
+6
View File
@@ -23,6 +23,12 @@ export interface AuthRouteDefinition {
/** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [
{
path: "/api/auth/session/verify",
group: "sessions",
handler: "verifySession",
methods: ["GET"],
},
{ path: "/api/auth/register", group: "registration", handler: "register", methods: ["POST"] },
{ path: "/api/auth/login", group: "login", handler: "login", methods: ["POST"] },
{ path: "/api/auth/logout", group: "login", handler: "logout", methods: ["POST"] },
+8 -2
View File
@@ -1,11 +1,17 @@
import type { Middleware } from "@wrnexus/core";
import { authSession } from "../middleware.ts";
import { getDefaultAuthEngine, hasDefaultAuthEngine } from "../runtime.ts";
import {
getDefaultAuthEngine,
getDefaultAuthRouteOptions,
hasDefaultAuthEngine,
} from "../runtime.ts";
/** Package middleware: hydrates auth state when a default engine is configured. */
const middleware: Middleware = async (ctx, next) => {
if (!hasDefaultAuthEngine()) return next();
return authSession(getDefaultAuthEngine())(ctx, next);
return authSession(getDefaultAuthEngine(), {
cookieName: getDefaultAuthRouteOptions().sessionCookieName,
})(ctx, next);
};
export default middleware;
+3
View File
@@ -1,6 +1,7 @@
import type { Context } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions {
@@ -11,6 +12,8 @@ export interface DefaultAuthRouteOptions {
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
}
interface AuthRuntimeState {
+6
View File
@@ -241,6 +241,12 @@ export type AuthSignedInHandler = (ctx: Context, returnTo?: string) => Response
/** HTTP response hook invoked after the auth engine clears a signed-in session. */
export type AuthSignedOutHandler = (ctx: Context) => Response | Promise<Response>;
/** Forward-auth response hook. A null user means the request is unauthenticated. */
export type AuthSessionVerificationHandler = (
ctx: Context,
user: AuthPublicUser | null,
) => Response | Promise<Response>;
export interface AuthSuccessfulSignUpAction {
/**
* Run the normal login policy with the newly registered credentials and
+42
View File
@@ -51,6 +51,48 @@ test("passwordless and passkey components preserve MFA continuation metadata", (
expect(signIn).toContain('name="deviceName"');
});
test("recovery forms expose a form slot for CAPTCHA and policy controls", () => {
const source = readFileSync(join(directory, "ForgotPassword.wrn"), "utf8");
const formStart = source.indexOf("<form");
const formEnd = source.indexOf("</form>");
const slot = source.indexOf("<slot></slot>");
expect(formStart).toBeGreaterThanOrEqual(0);
expect(slot).toBeGreaterThan(formStart);
expect(slot).toBeLessThan(formEnd);
});
test("account forms use the dedicated password components", () => {
const signIn = readFileSync(join(directory, "SignIn.wrn"), "utf8");
const signUp = readFileSync(join(directory, "SignUp.wrn"), "utf8");
expect(signIn).toContain("<TogglePassword");
expect(signIn).not.toContain('id="auth-sign-in-password"');
expect(signUp).toContain("<StrongPassword");
expect(signUp).not.toContain('id="auth-sign-up-password"');
});
test("authentication journeys use themed UI form controls", () => {
const expected = {
"SignIn.wrn": ["<Input", "<TogglePassword", "<Checkbox", "<Button"],
"SignUp.wrn": ["<Input", "<StrongPassword", "<Checkbox", "<Button"],
"ForgotPassword.wrn": ["<Input", "<Button"],
"ResetPassword.wrn": ["<StrongPassword", "<Button"],
"MagicLinkSignIn.wrn": ["<Input", "<Button"],
"InvitationAccept.wrn": ["<Input", "<StrongPassword", "<Button"],
"OtpSignIn.wrn": ["<Input", "<PinInput", "<Button"],
"TwoFactorChallenge.wrn": ["<Select", "<PinInput", "<Button"],
"AuthenticatorSetup.wrn": ["<Input", "<PinInput", "<Button"],
"VerifyEmail.wrn": ["<Input", "<Button"],
"VerifyPhone.wrn": ["<PinInput", "<Button"],
};
for (const [file, controls] of Object.entries(expected)) {
const source = readFileSync(join(directory, file), "utf8");
for (const control of controls) expect(source).toContain(control);
}
});
test("verification components can resend without application-owned schema files", () => {
const email = readFileSync(join(directory, "VerifyEmail.wrn"), "utf8");
const phone = readFileSync(join(directory, "VerifyPhone.wrn"), "utf8");
+20
View File
@@ -178,6 +178,26 @@ test("config.auth registers package routes", async () => {
expect(contributions.migrations).toHaveLength(0);
});
test("config.auth contributes a reusable forward-auth verification route", async () => {
const runner = createPluginRunner(authPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata: new Map<string, unknown>(),
warn() {},
});
await runner.configure({
auth: {
engine: {} as never,
routes: true,
},
});
const contributions = await runner.contributions();
expect(contributions.routes.some((route) => route.path === "/api/auth/session/verify")).toBe(
true,
);
});
test("config.auth can disable route groups", async () => {
const metadata = new Map<string, unknown>();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+27 -5
View File
@@ -415,7 +415,12 @@
}
function toggleItem(state, itemId, button) {
if (state.config.disabled || state.status !== "ready") return;
if (state.config.disabled || (state.status !== "ready" && state.status !== "incorrect")) return;
// An incorrect answer is still the same usable challenge until the server
// expires it or exhausts its attempts. Let the user amend the selection
// immediately and clear the stale error/locked control state.
if (state.status === "incorrect") setStatus(state, "ready", "");
var index = state.selections.indexOf(itemId);
var selected = index >= 0;
@@ -867,10 +872,8 @@
});
}
function onFormSubmit(state, event) {
if (!state.config.required || state.responseToken) return;
event.preventDefault();
event.stopImmediatePropagation();
function passesSubmitGuard(state) {
if (!state.config.required || state.responseToken) return true;
setStatus(state, "incorrect", state.config.requiredMessage);
emit(state, "failure", { code: "missing-input" });
var target =
@@ -878,6 +881,15 @@
state.root.querySelector("[data-captcha-not-robot-button]") ||
state.root.querySelector("[data-captcha-verify]");
if (target && typeof target.focus === "function") target.focus();
return false;
}
function onFormSubmit(state, event) {
// Schema-backed forms run this guard after their ordinary fields validate.
if (state.form && state.form.__wireValidateBound) return;
if (passesSubmitGuard(state)) return;
event.preventDefault();
event.stopImmediatePropagation();
}
function initialize(root) {
@@ -963,6 +975,11 @@
});
if (state.form) {
state.submitGuard = function () {
return passesSubmitGuard(state);
};
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards || [];
state.form.__wireSubmitGuards.push(state.submitGuard);
state.formSubmitListener = function (event) {
onFormSubmit(state, event);
};
@@ -1018,6 +1035,11 @@
if (state.form && state.formSubmitListener) {
state.form.removeEventListener("submit", state.formSubmitListener, true);
}
if (state.form && state.form.__wireSubmitGuards && state.submitGuard) {
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards.filter(function (guard) {
return guard !== state.submitGuard;
});
}
if (state.form && state.formSuccessListener) {
state.form.removeEventListener("wire:success", state.formSuccessListener);
}
+21 -17
View File
@@ -41,6 +41,7 @@ component Captcha {
required = true
resetOnError = true
compact = false
layout = "vertical"
@event ready = function
@event challenge = function
@@ -90,6 +91,7 @@ component Captcha {
data-captcha-required='{required}'
data-captcha-reset-on-error='{resetOnError}'
data-captcha-compact='{compact}'
data-captcha-layout='{layout}'
data-captcha-required-message='{requiredMessage}'
data-captcha-incorrect-message='{incorrectMessage}'
data-captcha-expired-message='{expiredMessage}'
@@ -97,9 +99,9 @@ component Captcha {
data-captcha-status="idle"
data-server-verification-required="true"
aria-busy="false"
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-layout=horizontal]:max-w-2xl data-[captcha-layout=horizontal]:grid data-[captcha-layout=horizontal]:grid-cols-1 sm:data-[captcha-layout=horizontal]:grid-cols-[minmax(0,1fr)_minmax(12rem,0.8fr)] sm:data-[captcha-layout=horizontal]:items-start data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-type=not-robot]/captcha:hidden'>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-layout=horizontal]/captcha:col-span-full group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='min-w-0'>
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
<span
@@ -140,7 +142,7 @@ component Captcha {
Loading challenge…
</div>
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-challenge hidden class='flex min-w-0 flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
@@ -219,20 +221,6 @@ component Captcha {
</button>
</template>
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<input
data-captcha-honeypot
type="text"
@@ -245,6 +233,21 @@ component Captcha {
/>
</div>
<div data-captcha-response-panel class='flex min-w-0 flex-col gap-2 group-data-[captcha-layout=horizontal]/captcha:self-stretch'>
<label data-captcha-answer-wrap hidden class='flex min-w-0 flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-success-message>Verification completed.</span>
@@ -312,6 +315,7 @@ component Captcha {
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
Protected by WRNexus CAPTCHA. Server-side verification is required.
</p>
</div>
</section>
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.5.1",
"version": "0.5.10",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
@@ -38,7 +38,7 @@
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck \u0026\u0026 bun run test"
"check": "bun run typecheck && bun run test"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
+18 -4
View File
@@ -38,6 +38,13 @@ const DEFAULT_TOKEN_TTL_MS = 300_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_MIN_COMPLETION_MS = 800;
const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response";
const RANDOM_CHALLENGE_TYPES: CaptchaChallengeType[] = [
"number",
"alpha",
"alphanumeric",
"calculation",
"image",
];
function failure(
action: string,
@@ -150,13 +157,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
for (const generator of options.generators ?? defaultCaptchaGenerators())
this.generators.set(generator.type, generator);
if (!this.generators.has(this.defaultType))
if (this.defaultType !== "random" && !this.generators.has(this.defaultType))
throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
}
async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
const action = assertAction(options.action);
const requestedType = options.type ?? this.defaultType;
const configuredType = options.type ?? this.defaultType;
const randomInt = (min: number, max: number): number =>
randomInteger(this.randomBytes, min, max);
const randomTypes = RANDOM_CHALLENGE_TYPES.filter((type) => this.generators.has(type));
if (configuredType === "random" && randomTypes.length === 0)
throw new Error("No interactive CAPTCHA generators are registered");
const requestedType =
configuredType === "random"
? randomTypes[randomInt(0, randomTypes.length - 1)]
: configuredType;
const requestedPresentation =
options.presentation ??
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
@@ -169,8 +185,6 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
const now = this.now();
const difficulty = options.difficulty ?? this.defaultDifficulty;
const disturbance = normalizeDisturbance(options.disturbance, difficulty);
const randomInt = (min: number, max: number): number =>
randomInteger(this.randomBytes, min, max);
const imageStyle = resolveCaptchaImageStyle({
imageStyle: options.imageStyle,
allowedStyles: options.allowedStyles,
+1
View File
@@ -4,6 +4,7 @@ export type CaptchaProviderName =
"self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
export type CaptchaChallengeType =
| "random"
| "number"
| "alpha"
| "alphanumeric"
@@ -23,9 +23,14 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
expect(source).toContain("normalizeSize");
expect(source).toContain("verifyNotRobot");
expect(source).toContain("stopImmediatePropagation");
expect(source).toContain("__wireSubmitGuards");
expect(source).toContain("__wireValidateBound");
expect(source).toContain("passesSubmitGuard");
expect(source).toContain('addEventListener("submit"');
expect(source).toContain('addEventListener("wire:error"');
expect(source).toContain("state.config.resetOnError || captchaFailed");
expect(source).toContain('state.status !== "ready" && state.status !== "incorrect"');
expect(source).toContain('state.status === "incorrect") setStatus(state, "ready", "")');
expect(source).toContain("Verified response tokens are single-use");
expect(source).toContain("MutationObserver");
expect(source).toContain("new CustomEvent(name");
+16
View File
@@ -49,6 +49,22 @@ function fixture() {
}
describe("self-hosted CAPTCHA engine", () => {
test("random challenges resolve to an interactive challenge type", async () => {
const engine = createCaptchaEngine({
secret: "captcha-test-secret-with-at-least-thirty-two-characters",
store: new MemoryCaptchaStore(),
defaultType: "random",
minCompletionMs: 0,
});
const supported = new Set(["number", "alpha", "alphanumeric", "calculation", "image"]);
for (let index = 0; index < 12; index += 1) {
const challenge = await engine.create({ action: "random-check" });
expect(supported.has(challenge.type)).toBe(true);
expect(challenge.type).not.toBe("random");
}
});
test("creates, solves, and consumes a challenge and response token", async () => {
const { engine } = fixture();
const challenge = await engine.create({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1
View File
@@ -419,6 +419,7 @@ await createProductionServer(
security: ${JSON.stringify(config.security ?? {})},
observability: ${JSON.stringify(config.observability ?? {})},
tenancy: ${JSON.stringify(config.tenancy ?? {})},
navigation: ${JSON.stringify(config.navigation ?? {})},
},
);
`;
+6 -14
View File
@@ -263,16 +263,14 @@ const config: AppConfig = {
},
},
// Fonts — the framework emits optimized <head> markup (preconnect, subsetted
// Google Fonts with font-display, self-hosted @font-face with preload) and
// auto-extends the CSP for Google Fonts. Uncomment to use a custom font:
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
fonts: {
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
},
//
// fonts: {
// sans: '"Inter", ui-sans-serif, system-ui, sans-serif',
// google: [{ family: "Inter", weights: [400, 500, 600, 700] }],
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
// },
// security: {
// cors: {
@@ -304,13 +302,7 @@ Allow: /
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
body {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
}
`,
"app/layouts/document.wrn": `// Global document layout. The framework renders this once around the selected
+26
View File
@@ -1103,6 +1103,32 @@ const MIGRATIONS: Migration[] = [
// Syntax and compiler support are package-managed; existing applications remain compatible.
},
},
{
version: "0.5.2",
id: "multiline-wrn-json-and-stable-gateway-runtime",
description:
"Supports formatted multiline array and object state initializers and keeps gateway children on the matching framework runtime.",
apply() {
// Syntax, compiler, and gateway fixes are package-managed; no application rewrite is required.
},
},
{
version: "0.5.3",
id: "public-workspace-origins-and-schema-backed-auth-forms",
description:
"Keeps cross-app redirects on configured public origins and enables schema-backed auth forms with active navigation states.",
apply() {
// Gateway, helper, validation, and UI improvements are package-managed.
},
},
{
version: "0.5.10",
id: "latest-release-after-upgrade",
description: "Latest New Packages and release ready packages",
apply() {
// Gateway, helper, validation, and UI improvements are package-managed.
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
+7 -3
View File
@@ -69,9 +69,13 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", ()
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118");
expect(readFileSync(join(root, "app", "styles", "global.css"), "utf8")).toContain(
'@plugin "@iconify/tailwind4";',
);
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
expect(globalCss).toContain('"Plus Jakarta Sans"');
expect(globalCss).toContain("--wire-font-sans");
const appConfig = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
expect(appConfig).toContain('family: "Plus Jakarta Sans"');
expect(appConfig).toContain("weights: [400, 500, 600, 700]");
expect(existsSync(join(root, "public", "llms.txt"))).toBe(true);
expect(existsSync(join(root, "llms.txt"))).toBe(false);
expect(readFileSync(join(root, ".prettierignore"), "utf8")).toContain("CLAUDE.md");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+10 -1
View File
@@ -1437,6 +1437,10 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
}
}
const isExplicitComponentMount = node.attrs.some(
(attribute) => attribute.name === "data-component",
);
const attrs = node.attrs
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
.map((a) => {
@@ -1476,7 +1480,12 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
if (a.value === "true" || a.value === "") return ` ${a.name}`;
}
const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`;
const wholeExpression = wholeAttributeExpression(a.value);
const compiledValue =
isExplicitComponentMount && wholeExpression
? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}`
: compileAttrValue(a.value, elementContext);
const rendered = ` ${a.name}="${compiledValue}"`;
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
+14
View File
@@ -98,6 +98,20 @@ test("component event declarations compile to public root metadata", async () =>
expect(render({})).toContain('data-wrn-events="complete"');
});
test("explicit nested component mounts serialize structured expression props", () => {
const output = compileWireFile(`component Shell {
state brand = { "label": "WRNexus" }
state items = [{ "label": "Home", "href": "/" }]
view {
<div data-component="Navbar" brand={brand} items={items}></div>
}
}`);
expect(output).toContain('data-component="Navbar"');
expect(output).toContain('brand="${__wireProp(brand)}"');
expect(output).toContain('items="${__wireProp(items)}"');
});
test("typed props, required props, state, custom types, and function parameters compile", () => {
const source = `component TypedPicker {
types {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+224
View File
@@ -2011,10 +2011,234 @@ export const REACTIVE_RUNTIME = String.raw`
}
ensureBehaviorObserver();
hydrateNavbarControllers(host);
hydratePreferenceControllers(host);
hydrateSidebarControllers(host);
hydrateDropdownControllers(host);
hydrateSelectControllers(host);
hydratePinInputControllers(host);
}
var navbarOutsideClickBound = false;
var preferenceOutsideClickBound = false;
var dropdownOutsideClickBound = false;
function hydrateSidebarControllers(root) {
var host = root || document;
var sidebars = [];
if (host.nodeType === 1 && host.matches && host.matches(".wire-sidebar-shell")) {
sidebars.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(sidebars, host.querySelectorAll(".wire-sidebar-shell"));
}
sidebars.forEach(function (sidebar) {
var current = window.location.pathname.replace(/\/+$/, "") || "/";
var best = null;
var bestLength = -1;
sidebar.querySelectorAll(".wire-sidebar-items a[href]").forEach(function (link) {
var url;
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
var path = url.pathname.replace(/\/+$/, "") || "/";
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
link.classList.remove("is-active");
if (!matches || path.length <= bestLength) return;
best = link;
bestLength = path.length;
});
sidebar.querySelectorAll(".wire-sidebar-items a[aria-current='page']").forEach(function (link) {
if (link !== best) link.removeAttribute("aria-current");
});
sidebar.querySelectorAll(".wire-sidebar-group.has-active").forEach(function (group) {
group.classList.remove("has-active");
});
if (best) {
best.classList.add("is-active");
best.setAttribute("aria-current", "page");
var group = best.closest(".wire-sidebar-group");
if (group) {
group.setAttribute("open", "");
group.classList.add("has-active");
}
}
});
}
function hydrateDropdownControllers(root) {
var host = root || document;
var dropdowns = [];
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-dropdown]")) {
dropdowns.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(dropdowns, host.querySelectorAll("[data-wrn-dropdown]"));
}
dropdowns.forEach(function (dropdown) {
if (dropdown.dataset.wrnDropdownBound === "true") return;
dropdown.dataset.wrnDropdownBound = "true";
dropdown.addEventListener("toggle", function () {
if (!dropdown.open) return;
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (candidate) {
if (candidate !== dropdown) candidate.removeAttribute("open");
});
});
});
if (!dropdownOutsideClickBound) {
dropdownOutsideClickBound = true;
document.addEventListener("pointerdown", function (event) {
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
if (!dropdown.contains(event.target)) dropdown.removeAttribute("open");
});
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
dropdown.removeAttribute("open");
var summary = dropdown.querySelector(":scope > summary");
if (summary) summary.focus();
});
});
}
}
function hydratePreferenceControllers(root) {
var host = root || document;
var switchers = [];
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-preferences]")) {
switchers.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(
switchers,
host.querySelectorAll("[data-wrn-preferences]"),
);
}
switchers.forEach(function (switcher) {
if (switcher.dataset.wrnPreferencesBound === "true") return;
switcher.dataset.wrnPreferencesBound = "true";
switcher.addEventListener("toggle", function (event) {
var opened = event.target;
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
if (menu !== opened) menu.removeAttribute("open");
});
}, true);
});
if (!preferenceOutsideClickBound) {
preferenceOutsideClickBound = true;
document.addEventListener("pointerdown", function (event) {
document.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
if (switcher.contains(event.target)) return;
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
menu.removeAttribute("open");
});
});
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
document.querySelectorAll("[data-wrn-preferences] .wire-preferences__menu[open]").forEach(function (menu) {
menu.removeAttribute("open");
var summary = menu.querySelector(":scope > summary");
if (summary) summary.focus();
});
});
}
}
function hydrateNavbarControllers(root) {
var host = root || document;
var navbars = [];
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-navbar]")) {
navbars.push(host);
}
if (host.querySelectorAll) {
Array.prototype.push.apply(
navbars,
host.querySelectorAll("[data-wrn-navbar]"),
);
}
navbars.forEach(function (navbar) {
var current = window.location.pathname.replace(/\/+$/, "") || "/";
var best = null;
var bestLength = -1;
navbar.querySelectorAll(".wire-navbar__menus a[href]").forEach(function (link) {
var url;
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
if (url.origin !== window.location.origin) return;
var path = url.pathname.replace(/\/+$/, "") || "/";
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
if (!matches || path.length <= bestLength) return;
best = link;
bestLength = path.length;
});
navbar.querySelectorAll(".wire-navbar__menus [aria-current='page']").forEach(function (item) {
item.removeAttribute("aria-current");
});
if (best) {
best.setAttribute("aria-current", "page");
var parentMenu = best.closest(".wire-navbar__dropdown");
var parentSummary = parentMenu && parentMenu.querySelector(":scope > summary");
if (parentSummary) parentSummary.setAttribute("aria-current", "page");
}
if (navbar.dataset.wrnNavbarBound === "true") return;
navbar.dataset.wrnNavbarBound = "true";
var hoverEnabled = navbar.getAttribute("data-open-on-hover") === "true";
var hoverCapable =
typeof window.matchMedia !== "function" ||
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (hoverEnabled && hoverCapable) {
navbar.querySelectorAll(".wire-navbar__dropdown").forEach(function (menu) {
menu.addEventListener("pointerenter", function () {
if (menu.__wrnNavbarCloseTimer) {
clearTimeout(menu.__wrnNavbarCloseTimer);
menu.__wrnNavbarCloseTimer = null;
}
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (candidate) {
if (candidate !== menu) candidate.removeAttribute("open");
});
menu.setAttribute("open", "");
});
menu.addEventListener("pointerleave", function () {
if (menu.__wrnNavbarCloseTimer) clearTimeout(menu.__wrnNavbarCloseTimer);
menu.__wrnNavbarCloseTimer = setTimeout(function () {
menu.removeAttribute("open");
menu.__wrnNavbarCloseTimer = null;
}, 240);
});
});
}
navbar.addEventListener("toggle", function (event) {
var opened = event.target;
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
if (menu !== opened) menu.removeAttribute("open");
});
}, true);
});
if (!navbarOutsideClickBound) {
navbarOutsideClickBound = true;
document.addEventListener("pointerdown", function (event) {
document.querySelectorAll("[data-wrn-navbar]").forEach(function (navbar) {
if (navbar.contains(event.target)) return;
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
menu.removeAttribute("open");
});
});
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
document.querySelectorAll("[data-wrn-navbar] .wire-navbar__dropdown[open]").forEach(function (menu) {
menu.removeAttribute("open");
var summary = menu.querySelector(":scope > summary");
if (summary) summary.focus();
});
});
}
}
var selectOutsideClickBound = false;
function selectScopeApi(root) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+12 -5
View File
@@ -23,14 +23,21 @@ interface ExecMeta {
insertId?: number | bigint;
}
function runnerFor(client: BunSqlClient): TxHandle {
/** Convert the framework's portable positional placeholders for PostgreSQL. */
export function sqlForDialect(sql: string, dialect: Dialect): string {
if (dialect !== "postgres" || !sql.includes("?")) return sql;
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
function runnerFor(client: BunSqlClient, dialect: Dialect): TxHandle {
return {
async query(sql, params = []): Promise<Row[]> {
const rows = (await client.unsafe(sql, params)) as Iterable<Row>;
const rows = (await client.unsafe(sqlForDialect(sql, dialect), params)) as Iterable<Row>;
return Array.from(rows);
},
async exec(sql, params = []) {
const meta = (await client.unsafe(sql, params)) as ExecMeta;
const meta = (await client.unsafe(sqlForDialect(sql, dialect), params)) as ExecMeta;
const id = meta.lastInsertRowid ?? meta.insertId;
return {
changes: Number(meta.affectedRows ?? meta.count ?? 0),
@@ -44,13 +51,13 @@ function runnerFor(client: BunSqlClient): TxHandle {
export function bunSql(url: string, dialect: Dialect): Driver {
const Ctor = (Bun as unknown as { SQL: new (u: string) => BunSqlClient }).SQL;
const client = new Ctor(url);
const runner = runnerFor(client);
const runner = runnerFor(client, dialect);
return {
dialect,
query: runner.query,
exec: runner.exec,
transaction(fn) {
return client.begin((tx) => fn(runnerFor(tx)));
return client.begin((tx) => fn(runnerFor(tx, dialect)));
},
close() {
return client.close();
+22 -6
View File
@@ -12,7 +12,9 @@
import type { Db } from "./driver.ts";
const DEFAULT = "default";
const registry = new Map<string, Db>();
type DbFactory = () => Db;
type RegistryEntry = { db?: Db; factory?: DbFactory };
const registry = new Map<string, RegistryEntry>();
/** Set the default database (called by the runtime at startup). */
export function setDb(db: Db): Db;
@@ -21,7 +23,7 @@ export function setDb(name: string, db: Db): Db;
export function setDb(a: string | Db, b?: Db): Db {
const name = typeof a === "string" ? a : DEFAULT;
const db = typeof a === "string" ? b! : a;
registry.set(name, db);
registry.set(name, { db });
return db;
}
@@ -30,10 +32,18 @@ export function registerDb(name: string, db: Db): Db {
return setDb(name, db);
}
/**
* Register a named database without opening its connection pool. The first
* `getDb(name)` call creates and caches the connection.
*/
export function registerLazyDb(name: string, factory: DbFactory): void {
registry.set(name, { factory });
}
/** The default database, or a named one. Throws if it isn't configured. */
export function getDb(name = DEFAULT): Db {
const db = registry.get(name);
if (!db) {
const entry = registry.get(name);
if (!entry) {
throw new Error(
name === DEFAULT
? "No database configured. Add `db: { driver, url }` to wrnexus.config.ts."
@@ -41,7 +51,11 @@ export function getDb(name = DEFAULT): Db {
`(e.g. databases: { ${name}: { driver, url } }).`,
);
}
return db;
if (!entry.db) {
if (!entry.factory) throw new Error(`Database '${name}' has no connection factory.`);
entry.db = entry.factory();
}
return entry.db;
}
/** Whether the default (or a named) database has been configured. */
@@ -56,6 +70,8 @@ export function databaseNames(): string[] {
/** Close every configured database and clear the registry. */
export async function closeDatabases(): Promise<void> {
for (const db of registry.values()) await db.close();
for (const entry of registry.values()) {
if (entry.db) await entry.db.close();
}
registry.clear();
}
+9 -1
View File
@@ -10,7 +10,15 @@ export { v, table, Column } from "./schema.ts";
export type { Model, Columns, ColumnDef, BaseType } from "./schema.ts";
export { createDb } from "./driver.ts";
export type { Db, Driver, Row, ExecResult, TxHandle } from "./driver.ts";
export { setDb, getDb, hasDb, registerDb, databaseNames, closeDatabases } from "./client.ts";
export {
setDb,
getDb,
hasDb,
registerDb,
registerLazyDb,
databaseNames,
closeDatabases,
} from "./client.ts";
export { createTableSql } from "./sql.ts";
export type { Dialect } from "./sql.ts";
export {
+1
View File
@@ -65,6 +65,7 @@ export async function appliedMigrations(db: Db): Promise<string[]> {
/** Apply an ordered migration list (each in a transaction). Returns applied names. */
export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise<string[]> {
if (migrations.length === 0) return [];
const applied = new Set(await appliedMigrations(db));
const pending = migrations.filter((migration) => !applied.has(migration.name));
const done: string[] = [];
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { sqlForDialect } from "../src/adapters/bunsql.ts";
test("PostgreSQL converts portable placeholders to numbered parameters", () => {
expect(sqlForDialect("SELECT * FROM users WHERE id = ? AND status = ?", "postgres")).toBe(
"SELECT * FROM users WHERE id = $1 AND status = $2",
);
});
test("MySQL and parameter-free PostgreSQL statements remain unchanged", () => {
expect(sqlForDialect("SELECT * FROM users WHERE id = ?", "mysql")).toBe(
"SELECT * FROM users WHERE id = ?",
);
expect(sqlForDialect("CREATE TABLE users (id TEXT)", "postgres")).toBe(
"CREATE TABLE users (id TEXT)",
);
});
+16
View File
@@ -8,12 +8,14 @@ import {
createDb,
createTableSql,
parseMigration,
applyMigrations,
migrate,
status,
rollback,
parseQueries,
generateQueriesFile,
} from "../src/index.ts";
import type { Db } from "../src/index.ts";
import { sqlite } from "../src/adapters/sqlite.ts";
import { bunSql } from "../src/adapters/bunsql.ts";
@@ -95,6 +97,20 @@ test("migration runner: parse, migrate, status, rollback", async () => {
db.close();
});
test("an empty migration set does not touch the database", async () => {
const unreachable = async () => {
throw new Error("database should remain lazy");
};
const db = {
all: unreachable,
one: unreachable,
exec: unreachable,
tx: unreachable,
} as unknown as Db;
expect(await applyMigrations(db, [])).toEqual([]);
});
test("query generator infers params and result types", () => {
const q = parseQueries(
"-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" +
+33
View File
@@ -5,6 +5,7 @@ import {
getDb,
hasDb,
registerDb,
registerLazyDb,
databaseNames,
closeDatabases,
} from "../src/index.ts";
@@ -45,3 +46,35 @@ test("getDb throws a helpful error for an unknown named database", async () => {
expect(() => getDb("nope")).toThrow(/No database named 'nope'/);
await closeDatabases();
});
test("named databases can be registered without opening a connection", async () => {
await closeDatabases();
let calls = 0;
const analytics = createDb(sqlite(":memory:"));
registerLazyDb("analytics", () => {
calls++;
return analytics;
});
expect(hasDb("analytics")).toBe(true);
expect(databaseNames()).toEqual(["analytics"]);
expect(calls).toBe(0);
expect(getDb("analytics")).toBe(analytics);
expect(getDb("analytics")).toBe(analytics);
expect(calls).toBe(1);
await closeDatabases();
});
test("closing the registry does not instantiate unused lazy databases", async () => {
await closeDatabases();
let calls = 0;
registerLazyDb("unused", () => {
calls++;
return createDb(sqlite(":memory:"));
});
await closeDatabases();
expect(calls).toBe(0);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+40 -8
View File
@@ -10,7 +10,8 @@
*/
import { spawn, type ChildProcess } from "node:child_process";
import { join, resolve } from "node:path";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { RESTART_EXIT_CODE } from "./restart.ts";
@@ -150,11 +151,15 @@ export function stripInternalError(res: Response): Response {
}
/** Preserve an intentional verifier redirect while keeping other failures opaque. */
export function forwardAuthFailure(res: Response, verifierUrl: string): Response {
export function forwardAuthFailure(
res: Response,
verifierUrl: string,
verifierPublicOrigin = verifierUrl,
): Response {
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
try {
const redirect = new URL(location, verifierUrl);
const redirect = new URL(location, verifierPublicOrigin);
if (redirect.protocol === "http:" || redirect.protocol === "https:") {
return new Response(null, { status: res.status, headers: { location: redirect.href } });
}
@@ -197,6 +202,7 @@ async function checkAuth(
req: Request,
ip: string,
internalOrigins: Readonly<Record<string, string>>,
publicOrigins: Readonly<Record<string, string>>,
publicOrigin?: string,
): Promise<Response | null> {
if (!auth) return null;
@@ -226,6 +232,10 @@ async function checkAuth(
if (auth.forward) {
const verifyUrl = resolveForwardAuthUrl(auth.forward, internalOrigins);
const verifierPublicOrigin =
typeof auth.forward.app === "string"
? publicOrigins[auth.forward.app]
: new URL(auth.forward.url).origin;
try {
const res = await fetch(verifyUrl, {
@@ -240,7 +250,7 @@ async function checkAuth(
`[wrnexus] forward-auth verifier error: ${req.method} ${requestUrl.pathname} via ${verifyUrl} returned ${res.status}${diagnostic ? `${diagnostic}` : ""}`,
);
}
return forwardAuthFailure(res, verifyUrl);
return forwardAuthFailure(res, verifyUrl, verifierPublicOrigin);
}
} catch (error) {
console.error(
@@ -351,11 +361,21 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
const hostname = opts.hostname ?? defaultGatewayHostname(mode);
const environment = opts.environment ?? (mode === "production" ? "production" : "development");
const workspaceOrigins = Object.fromEntries(
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
const workspaceOrigins: Readonly<Record<string, string>> = Object.freeze(
Object.fromEntries(
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
),
);
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
// When the CLI is executed directly from a framework checkout, keep child
// apps on that same source tree. Resolving the package name from an external
// workspace can otherwise select its older installed release on restart.
const sourceServeEntry = fileURLToPath(new URL("./serve-entry.ts", import.meta.url));
const usesSourceServeEntry = existsSync(sourceServeEntry);
const serveEntry = usesSourceServeEntry
? sourceServeEntry
: fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
const frameworkRoot = resolve(dirname(sourceServeEntry), "../../..");
let stopping = false;
const targets: Target[] = opts.apps.map((app, i) => {
@@ -397,6 +417,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
],
{
stdio: "inherit",
// Bun can otherwise resolve bare @wrnexus imports against the
// external application's node_modules after an HMR restart.
// Keep source-checkout children anchored to the same framework
// checkout as the gateway command.
cwd: usesSourceServeEntry ? frameworkRoot : dir,
env: {
...process.env,
WRNEXUS_ENV: environment,
@@ -496,7 +521,14 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
}
// Per-app access control (basic auth / IP allowlist / forward-auth).
const denied = await checkAuth(target.auth, req, ip, internalOrigins, target.publicOrigin);
const denied = await checkAuth(
target.auth,
req,
ip,
internalOrigins,
workspaceOrigins,
target.publicOrigin,
);
if (denied) {
if (sec.accessLog)
console.log(
+20 -3
View File
@@ -16,11 +16,19 @@ import {
type ThemeConfig,
type MobileConfig,
type PwaConfig,
type NavigationConfig,
} from "@wrnexus/styles";
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
import { applyMigrations, migrate, setDb, registerDb } from "@wrnexus/db";
import {
applyMigrations,
loadMigrations,
migrate,
setDb,
registerDb,
registerLazyDb,
} from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
import { realtimeBusFromConfig } from "./realtime-bus.ts";
@@ -76,6 +84,7 @@ export interface ServeOptions {
plugins?: PluginInput;
observability?: ObservabilityConfig;
tenancy?: TenancyConfig;
navigation?: NavigationConfig;
}
export interface RunningServer {
@@ -217,6 +226,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
plugins: configuredPlugins,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
};
await pluginRunner.configure(pluginConfig);
@@ -304,8 +314,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
}
};
if (opts.db) await connectAndMigrate(null, opts.db);
for (const [name, cfg] of Object.entries(opts.databases ?? {}))
await connectAndMigrate(name, cfg);
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
const migrationDir = join(appDir, "db", name, "migrations");
const hasMigrations =
loadMigrations(migrationDir).length > 0 ||
resolvePackageMigrations(pluginContributions.migrations, name).length > 0;
if (hasMigrations) await connectAndMigrate(name, cfg);
else registerLazyDb(name, () => connectFromConfig(cfg, appRoot));
}
// File-upload storage: build a driver per configured store (local dir / S3).
// Relative local dirs resolve against the app root; served/served-back below.
@@ -358,6 +374,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
clientRuntimes: pluginContributions.clientRuntimes,
hub,
realtimeBus: realtimeBusFromConfig(opts.realtime),
+6 -9
View File
@@ -25,10 +25,11 @@ import {
type PwaConfig,
type ObservabilityConfig,
type TenancyConfig,
type NavigationConfig,
} from "@wrnexus/styles";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
import { setDb, registerDb, getDb, hasDb, migrate } from "@wrnexus/db";
import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import {
configureStorage,
@@ -133,6 +134,8 @@ export interface ProdOptions {
observability?: ObservabilityConfig;
/** Built-in tenant identity resolution. */
tenancy?: TenancyConfig;
/** Page navigation strategy. */
navigation?: NavigationConfig;
port?: number;
hostname?: string;
maxBodyBytes?: number;
@@ -287,14 +290,7 @@ export function createProductionHandlers(
}
}
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
try {
registerDb(name, connectFromConfig(cfg));
} catch (err) {
console.warn(
`[wrnexus] database '${name}' setup failed:`,
err instanceof Error ? err.message : err,
);
}
registerLazyDb(name, () => connectFromConfig(cfg));
}
// File-upload storage. Relative local dirs resolve against the deployment cwd
@@ -334,6 +330,7 @@ export function createProductionHandlers(
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
maxBodyBytes: opts.maxBodyBytes,
realtimeBus: realtimeBusFromConfig(opts.realtime),
});
+20 -6
View File
@@ -133,6 +133,8 @@ export interface RuntimeDeps {
assetVersion?: string;
/** Package browser runtimes resolved by the plugin system. */
clientRuntimes?: ClientRuntimeDefinition[];
/** Page navigation strategy. `document` disables same-origin link interception. */
navigation?: { mode?: "client" | "document" };
/** Raw HTML appended to every page head (e.g. CDN framework links). */
head?: string;
/** Global SEO defaults. */
@@ -1140,9 +1142,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd);
i = end;
const normalizedName = name.toLowerCase();
const normalizedName = normalizeComponentName(name);
const component = router.components.find(
(candidate) => candidate.name.toLowerCase() === normalizedName,
(candidate) => normalizeComponentName(candidate.name) === normalizedName,
);
if (!component) {
console.warn(`[wrnexus] no component registered for '${name}'`);
@@ -1312,7 +1314,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
if (deps.i18n) body = translateHtml(body, ctx.t);
// Point 3: only ship the JS this page actually uses.
const scripts = collectScripts(body, deps.clientRuntimes).map((script) =>
const scripts = collectScripts(body, deps.clientRuntimes, deps.navigation).map((script) =>
versionRenderScript(script, deps.assetVersion),
);
if (pwaServiceWorkerEnabled)
@@ -1669,6 +1671,17 @@ export function resolveTProps(
return props;
}
/**
* Normalize component identifiers for registry lookup.
*
* WRN declarations use PascalCase (`PublicHeader`) while explicit
* `data-component` mounts commonly use kebab-case (`public-header`) or
* snake_case (`public_header`). All three forms identify the same component.
*/
export function normalizeComponentName(name: string): string {
return name.toLowerCase().replace(/[-_]/g, "");
}
/**
* Decide which framework scripts a rendered page needs. Components are already
* server-rendered into the HTML; the only script is the reactive runtime, and
@@ -1677,10 +1690,11 @@ export function resolveTProps(
export function collectScripts(
body: string,
clientRuntimes: readonly ClientRuntimeDefinition[] = [],
navigation: { mode?: "client" | "document" } = {},
): RenderScript[] {
// Client-side navigation is an app-wide progressive enhancement: it must load
// on every page (you navigate *from* any page), and degrades to full loads.
const scripts: RenderScript[] = ["/__wrnexus/nav.js"];
// Client navigation is optional. In document mode, links retain native browser
// behavior and each route receives a fresh server-rendered HTML document.
const scripts: RenderScript[] = navigation.mode === "document" ? [] : ["/__wrnexus/nav.js"];
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
scripts.push("/__wrnexus/reactive.js");
}
+1
View File
@@ -60,6 +60,7 @@ const server = await startServer({
plugins: config.plugins,
observability: config.observability,
tenancy: config.tenancy,
navigation: config.navigation,
});
const router = server.router;
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test";
import { normalizeComponentName } from "../src/runtime.ts";
test("component lookup treats PascalCase, kebab-case, and snake_case as equivalent", () => {
expect(normalizeComponentName("PublicHeader")).toBe("publicheader");
expect(normalizeComponentName("public-header")).toBe("publicheader");
expect(normalizeComponentName("public_header")).toBe("publicheader");
});
test("component lookup preserves meaningful alphanumeric differences", () => {
expect(normalizeComponentName("Card2")).not.toBe(normalizeComponentName("Card"));
expect(normalizeComponentName("PublicFooter")).not.toBe(normalizeComponentName("PublicHeader"));
});
+13
View File
@@ -49,6 +49,19 @@ test("forward auth preserves intentional verifier redirects", () => {
expect(denied.headers.has("location")).toBe(false);
});
test("forward auth exposes the verifier public origin instead of its internal app port", () => {
const redirected = forwardAuthFailure(
new Response(null, {
status: 302,
headers: { location: "/sign-in?returnTo=%2Fadmin" },
}),
"http://127.0.0.1:85/api/verify",
"http://sso.localhost",
);
expect(redirected.headers.get("location")).toBe("http://sso.localhost/sign-in?returnTo=%2Fadmin");
});
test("gateway reads and strips internal app diagnostics", async () => {
const response = new Response("safe public error", {
status: 500,
@@ -65,3 +65,11 @@ test("collectScripts automatically adds a referenced package runtime once", () =
),
).toHaveLength(1);
});
test("collectScripts omits client navigation in document mode", () => {
const scripts = collectScripts("<main>Server rendered</main>", [], {
mode: "document",
});
expect(scripts).not.toContain("/__wrnexus/nav.js");
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"sideEffects": false,
@@ -16,7 +16,7 @@
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck \u0026\u0026 bun run test"
"check": "bun run typecheck && bun run test"
},
"devDependencies": {
"@types/bun": "latest",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1
View File
@@ -144,6 +144,7 @@ export {
currentAppName,
currentAppOrigin,
workspaceAppOrigins,
workspaceRootDomain,
} from "./workspace.ts";
export {
backoffDelay,
+23
View File
@@ -56,3 +56,26 @@ export function currentAppOrigin(): string | undefined {
export function workspaceAppOrigins(): Readonly<Record<string, string>> {
return Object.freeze({ ...workspaceOrigins() });
}
/** Shared DNS suffix for configured workspace apps (for example `staging.example.com`). */
export function workspaceRootDomain(): string {
const hosts = Object.values(workspaceOrigins()).map((origin) =>
new URL(origin).hostname.toLowerCase(),
);
if (!hosts.length) {
throw new Error("Workspace origins are unavailable.");
}
const labels = hosts.map((host) => host.split(".").reverse());
const common: string[] = [];
for (let index = 0; index < labels[0]!.length; index++) {
const label = labels[0]![index];
if (!labels.every((parts) => parts[index] === label)) break;
common.push(label!);
}
if (!common.length) {
throw new Error(`Workspace apps do not share a root domain: ${hosts.join(", ")}.`);
}
return common.reverse().join(".");
}
+16
View File
@@ -9,6 +9,22 @@ import {
stableStringify,
} from "../src/index.ts";
test("derives the shared workspace root domain from configured public app origins", async () => {
const previous = process.env.WRNEXUS_WORKSPACE_ORIGINS;
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
admin: "https://admin.staging.example.com",
citizen: "https://citizen.staging.example.com",
sso: "https://sso.staging.example.com",
});
try {
const { workspaceRootDomain } = await import("../src/workspace.ts");
expect(workspaceRootDomain()).toBe("staging.example.com");
} finally {
if (previous === undefined) delete process.env.WRNEXUS_WORKSPACE_ORIGINS;
else process.env.WRNEXUS_WORKSPACE_ORIGINS = previous;
}
});
function context(url: string, headers: HeadersInit = {}) {
const parsed = new URL(url);
return createContext(new Request(parsed, { headers }), parsed);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+7
View File
@@ -156,7 +156,14 @@ export const I18N_RUNTIME = String.raw`
location.reload();
}
function bind(root) {
var currentLang = (window.__wireI18n && window.__wireI18n.lang) || document.documentElement.lang;
(root || document).querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
switcher.setAttribute("data-current-language", currentLang);
var currentLabel = switcher.querySelector(".wire-preferences__current-language");
if (currentLabel) currentLabel.textContent = String(currentLang || "en").toUpperCase();
});
(root || document).querySelectorAll("[data-wire-lang-set]").forEach(function (n) {
n.setAttribute("aria-pressed", String(n.getAttribute("data-wire-lang-set") === currentLang));
if (n.__wireLangBound) return; n.__wireLangBound = 1;
n.addEventListener("click", function () { set(n.getAttribute("data-wire-lang-set")); });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+16 -1
View File
@@ -182,6 +182,15 @@ export interface BuildConfig {
adapter?: "bun" | "node" | "static" | "serverless" | "edge" | string;
}
export interface NavigationConfig {
/**
* `client` progressively enhances same-origin links with in-place page swaps.
* `document` keeps normal browser navigation so every route performs a fresh
* server-rendered document request.
*/
mode?: "client" | "document";
}
export interface AppConfig {
/** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */
plugins?: PluginInput;
@@ -195,6 +204,8 @@ export interface AppConfig {
tenancy?: TenancyConfig;
/** Build cache, source map, report, and deployment adapter settings. */
build?: BuildConfig;
/** Page navigation strategy. Defaults to progressive client navigation. */
navigation?: NavigationConfig;
/** Development-only page diagnostics toolbar. Enabled by default in development. */
devToolbar?: boolean | DevToolbarConfig;
/** Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet links). */
@@ -294,8 +305,12 @@ export async function loadRawConfig(appRoot: string): Promise<AppConfig> {
/** Load `wrnexus.config.*`, applying the active profile's overrides. */
export async function loadAppConfig(appRoot: string, profile?: string): Promise<AppConfig> {
const base = await loadRawConfig(appRoot);
const active = profile ?? resolveProfile();
// Configuration modules commonly read DATABASE_URL and other settings during
// module evaluation. Load the profile cascade before importing the module so
// development and production behave consistently.
loadEnv(appRoot, active);
const base = await loadRawConfig(appRoot);
const override = base.profiles?.[active];
const merged: AppConfig = override ? deepMerge(base, override) : { ...base };
delete merged.profiles;
+1
View File
@@ -15,6 +15,7 @@ export type {
ExplainedConfig,
ExperimentalConfig,
MobileConfig,
NavigationConfig,
ObservabilityConfig,
PerformanceConfig,
TenancyConfig,
+81 -21
View File
@@ -243,18 +243,18 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"color-error": "#dc2626",
radius: "8px",
"radius-sm": "5px",
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
"shadow-1": "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)",
},
dark: {
"color-scheme": "dark",
"color-bg": "#0b1020",
"color-background": "#0b1020",
"color-foreground": "#e7ecff",
"color-surface-raised": "#1a223b",
"color-surface-muted": "#1c243f",
"color-text-muted": "#aab4d8",
"color-text-subtle": "#8995bd",
"color-bg": "#000000",
"color-background": "#000000",
"color-foreground": "#f5f5f5",
"color-surface-raised": "#111111",
"color-surface-muted": "#181818",
"color-text-muted": "#b3b3b3",
"color-text-subtle": "#8a8a8a",
"color-border-strong": "#ffffff38",
"color-primary-soft": "#6c8cff24",
"color-secondary-soft": "#a78bfa24",
@@ -262,10 +262,10 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"color-success-soft": "#4ade8024",
"color-warning-soft": "#fbbf2424",
"color-danger-soft": "#f8717124",
"color-code-background": "#060a15",
"color-code-surface": "#10172a",
"color-code-text": "#e7ecff",
"color-code-muted": "#9aa6d0",
"color-code-background": "#050505",
"color-code-surface": "#101010",
"color-code-text": "#f5f5f5",
"color-code-muted": "#a3a3a3",
"color-code-border": "#ffffff24",
"shadow-sm": "0 1px 2px rgba(0,0,0,0.28)",
"shadow-md": "0 10px 30px rgba(0,0,0,0.35)",
@@ -273,17 +273,17 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"space-section": "5rem",
"space-section-sm": "3.5rem",
"container-max": "80rem",
"color-surface": "#141a30",
"color-surface-2": "#1c243f",
"color-text": "#e7ecff",
"color-muted": "#9aa6d0",
"color-surface": "#0a0a0a",
"color-surface-2": "#171717",
"color-text": "#f5f5f5",
"color-muted": "#a3a3a3",
"color-border": "#ffffff1f",
"color-primary": "#6c8cff",
"color-primary-hover": "#8aa2ff",
"color-primary-contrast": "#0b1020",
"color-primary-contrast": "#000000",
"color-secondary": "#a78bfa",
"color-secondary-hover": "#c4b5fd",
"color-secondary-contrast": "#0b1020",
"color-secondary-contrast": "#000000",
"color-info": "#38bdf8",
"color-danger": "#f87171",
"color-success": "#4ade80",
@@ -291,7 +291,7 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"color-error": "#f87171",
radius: "10px",
"radius-sm": "6px",
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
"shadow-1": "0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)",
},
};
@@ -338,7 +338,10 @@ export function renderThemeCss(theme: ResolvedTheme): string {
for (const name of theme.names) {
blocks.push(`[data-theme="${name}"]{${tokensToDeclarations(theme.themes[name]!)}}`);
}
return blocks.join("\n") + "\n";
return (
blocks.join("\n") +
'\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n'
);
}
/**
@@ -351,14 +354,59 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
return `(function(){
var COOKIE=${JSON.stringify(THEME_COOKIE)};
var THEMES=${names};
var ACCENT_COOKIE="wire-accent";
var COOKIE_MAX_AGE=31536000;
var ACCENTS={
blue:["#2563eb","#1d4ed8","#ffffff"],
cyan:["#0891b2","#0e7490","#ffffff"],
emerald:["#059669","#047857","#ffffff"],
violet:["#7c3aed","#6d28d9","#ffffff"],
rose:["#e11d48","#be123c","#ffffff"],
amber:["#d97706","#b45309","#ffffff"]
};
var el=document.documentElement;
function readCookie(name){
var prefix=encodeURIComponent(name)+"=";
var parts=document.cookie?document.cookie.split(";"):[];
for(var i=0;i<parts.length;i++){
var part=parts[i].trim();
if(part.indexOf(prefix)===0){
try{return decodeURIComponent(part.slice(prefix.length));}catch(_){return part.slice(prefix.length);}
}
}
return null;
}
function writeCookie(name,value){
var secure=location.protocol==="https:"?";secure":"";
document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+";path=/;max-age="+COOKIE_MAX_AGE+";samesite=lax"+secure;
}
function get(){return el.getAttribute("data-theme")||${JSON.stringify(theme.default)};}
function syncThemeButtons(root){
(root||document).querySelectorAll("[data-wire-theme-set]").forEach(function(n){
n.setAttribute("aria-pressed",String(n.getAttribute("data-wire-theme-set")===get()));
});
}
function syncAccentButtons(root,name){
(root||document).querySelectorAll("[data-wire-accent-set]").forEach(function(n){
n.setAttribute("aria-pressed",String(n.getAttribute("data-wire-accent-set")===name));
});
}
function set(name){
if(THEMES.indexOf(name)<0)return;
el.setAttribute("data-theme",name);
document.cookie=COOKIE+"="+encodeURIComponent(name)+";path=/;max-age=31536000;samesite=lax";
writeCookie(COOKIE,name);
syncThemeButtons(document);
}
function toggle(){var i=THEMES.indexOf(get());set(THEMES[(i+1)%THEMES.length]);}
function setAccent(name){
var value=ACCENTS[name];if(!value)return;
el.style.setProperty("--wire-color-primary",value[0]);
el.style.setProperty("--wire-color-primary-hover",value[1]);
el.style.setProperty("--wire-color-primary-contrast",value[2]);
el.setAttribute("data-accent",name);
writeCookie(ACCENT_COOKIE,name);
syncAccentButtons(document,name);
}
function bind(root){
(root||document).querySelectorAll("[data-wire-theme-toggle]").forEach(function(n){
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
@@ -368,8 +416,20 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
n.addEventListener("click",function(){set(n.getAttribute("data-wire-theme-set"));});
});
(root||document).querySelectorAll("[data-wire-accent-set]").forEach(function(n){
if(n.__wireAccentBound)return;n.__wireAccentBound=1;
n.addEventListener("click",function(){setAccent(n.getAttribute("data-wire-accent-set"));});
});
syncThemeButtons(root);
syncAccentButtons(root,el.getAttribute("data-accent")||"blue");
}
function getAccent(){
var current=el.getAttribute("data-accent")||readCookie(ACCENT_COOKIE)||"blue";
return ACCENTS[current]?current:"blue";
}
window.wireTheme={get:get,set:set,toggle:toggle,bind:bind,themes:THEMES};
window.wireAccent={get:getAccent,set:setAccent,colors:Object.keys(ACCENTS)};
setAccent(getAccent());
window.addEventListener("wrnexus:navigated",function(){bind(document);});
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){bind(document);});
else bind(document);
+20
View File
@@ -67,6 +67,26 @@ test("loadEnv layers .env files by precedence; real env always wins", () => {
}
});
test("loadAppConfig loads env before evaluating the config module", async () => {
const dir = mkdtempSync(join(tmpdir(), "wire-config-env-"));
writeFileSync(join(dir, ".env"), "DATABASE_URL=postgres://pms-from-env\n");
writeFileSync(
join(dir, "wrnexus.config.mjs"),
`export default {
db: { driver: "postgres", url: process.env.DATABASE_URL },
};`,
);
try {
delete process.env.DATABASE_URL;
const config = await loadAppConfig(dir, "development");
expect(config.db?.url).toBe("postgres://pms-from-env");
} finally {
delete process.env.DATABASE_URL;
rmSync(dir, { recursive: true, force: true });
}
});
test("package-aware styles add relative imports and Tailwind scan sources", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-"));
try {
+12
View File
@@ -54,6 +54,17 @@ test("resolveThemeConfig merges user tokens over built-in light/dark", () => {
expect(t.themes.dark["color-bg"]).toBeDefined(); // built-in kept
});
test("built-in themes use Plus Jakarta Sans and neutral black dark surfaces", () => {
const t = resolveThemeConfig();
expect(t.themes.light["font-sans"]).toBe(
'"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
);
expect(t.themes.dark["font-sans"]).toBe('"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif');
expect(t.themes.dark["color-bg"]).toBe("#000000");
expect(t.themes.dark["color-surface"]).toBe("#0a0a0a");
expect(t.themes.dark["color-surface-2"]).toBe("#171717");
});
test("resolveThemeName validates against configured names", () => {
const t = resolveThemeConfig();
expect(resolveThemeName("light", t)).toBe("light");
@@ -71,6 +82,7 @@ test("renderThemeCss emits :root + per-theme blocks and --wire-* vars", () => {
expect(css).toContain('[data-theme="dark"]{');
expect(css).toContain("--wire-color-primary:#6c8cff");
expect(css).toContain("color-scheme:dark"); // reserved token → native property
expect(css).toContain("font-family:var(--wire-font-sans");
});
test("renderThemeRuntime bakes the theme names for cycling", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+5 -1
View File
@@ -409,7 +409,11 @@ export function parse(source: string): PageAst {
} else {
expect("eq");
}
states.push({ name: sName, valueType, expr: lx.readToLineEnd() });
// State values may be multiline structured expressions. Use the same
// balanced initializer reader as props so formatted arrays/objects
// remain one declaration instead of exposing their inner braces as
// component members on the next line.
states.push({ name: sName, valueType, expr: lx.readPropInitializer() });
break;
}
case "computed": {
+26
View File
@@ -30,6 +30,32 @@ component Navigation {
expect(sidebar.attrs.find((attr) => attr.name === "options")?.value).toBe('{{"dense":true}}');
});
test("parses formatted multiline arrays and objects in state", () => {
const ast = parse(`component Navigation {
state items = [
{
"label": "Home",
"href": "/"
},
{
"label": "Services",
"children": [
{ "label": "Reports", "href": "/reports" }
]
}
]
state options = {
"dense": true,
"columns": 4
}
view { <nav></nav> }
}`);
expect(ast.states[0]?.expr).toContain('"Services"');
expect(ast.states[0]?.expr).toContain('"Reports"');
expect(ast.states[1]?.expr).toContain('"columns": 4');
});
test("parses WRN 0.3 execution, data, security, and reactivity blocks", () => {
const ast = parse(`page Dashboard {
runtime = "universal"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.5.1",
"version": "0.5.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+57 -10
View File
@@ -1,6 +1,6 @@
# WRNexus UI component reference
This reference is generated from the 85 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
This reference is generated from the 90 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
## Advanced-forms
@@ -330,15 +330,35 @@ Theme-aware, responsive tree view component.
- Slots: `default`
- Events: `select`, `toggle`, `expand`, `collapse`
## Blocks
### PortalDashboard
Responsive portal dashboard with metrics, actions, tasks, and updates.
- Mount: `data-component="PortalDashboard"`
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Overview"`, `eyebrowKey: string = ""`, `title: string = "Dashboard"`, `titleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `userName: string = ""`, `metrics: string = []`, `actions: string = []`, `updates: string = []`, `tasks: string = []`, `class: string = ""`
- Slots: `hero-action`
- Events: `action`, `navigate`
## Forms
### AuthForm
Reusable authentication form for sign-in, registration, recovery, reset, and MFA.
- Mount: `data-component="AuthForm"`
- Props: `size: string = "default"`, `color: string = "primary"`, `mode: string = "sign-in"`, `action: string = "/api/auth/login"`, `method: string = "post"`, `title: string = "Sign in"`, `description: string = ""`, `returnTo: string = ""`, `schema: string = ""`, `showRemember: boolean = true`, `showName: boolean = true`, `submitLabel: string = "Continue"`, `class: string = ""`
- Slots: `default`
- Events: `submit`, `change`, `input`, `focus`, `blur`
### Checkbox
Theme-aware, responsive checkbox component.
- Mount: `data-component="Checkbox"`
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Checkbox"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `values: string = []`, `options: string = []`, `checked: boolean = false`, `indeterminate: boolean = false`, `orientation: string = "vertical"`, `card: boolean = false`, `rightAligned: boolean = false`, `list: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
- Slots: None
- Slots: `default`
- Events: `input`, `change`, `focus`, `blur`, `invalid`
### ColorPicker
@@ -543,6 +563,15 @@ Theme-aware, responsive wysiwyg editor component.
## Layout
### AuthSplitLayout
Responsive 50/50 authentication layout with content and form regions.
- Mount: `data-component="AuthSplitLayout"`
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Secure identity"`, `title: string = "Welcome back"`, `description: string = ""`, `brand: string = "Police Management System"`, `features: string = []`, `class: string = ""`
- Slots: `aside-extra`, `form`
- Events: None
### Columns
Theme-aware, responsive columns component.
@@ -644,6 +673,15 @@ Theme-aware, responsive breadcrumb component.
- Slots: `default`
- Events: `navigate`, `click`
### Footer
Responsive application footer with structured links and pre/post content slots.
- Mount: `data-component="Footer"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Footer navigation"`, `items: string = []`, `columns: number = 3`, `maxWidth: string = "compact"`, `copyright: string = ""`, `class: string = ""`
- Slots: `pre-footer`, `post-footer`, `copyright-left`, `copyright-right`
- Events: `select`, `action`
### MegaMenu
Theme-aware, responsive mega menu component.
@@ -667,9 +705,9 @@ Theme-aware, responsive nav component.
Theme-aware, responsive navbar component.
- Mount: `data-component="Navbar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Navbar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
- Slots: `default`
- Events: `toggle`, `open`, `close`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: string = {}`, `items: string = []`, `actions: string = []`, `active: string = ""`, `sticky: boolean = false`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `class: string = ""`
- Slots: `topbar`, `actions`
- Events: `toggle`, `open`, `close`, `select`, `action`
### Pagination
@@ -680,6 +718,15 @@ Theme-aware, responsive pagination component.
- Slots: `default`
- Events: `change`, `previous`, `next`
### PreferenceSwitcher
Accessible theme, accent color, and language preference controls.
- Mount: `data-component="PreferenceSwitcher"`
- Props: `size: string = "default"`, `color: string = "primary"`, `themeLabel: string = "Theme"`, `colorLabel: string = "Accent color"`, `languageLabel: string = "Language"`, `languages: string = [`, `colors: string = [`, `compact: boolean = true`, `class: string = ""`
- Slots: None
- Events: `theme`, `color`, `language`
### Scrollspy
Theme-aware, responsive scrollspy component.
@@ -694,9 +741,9 @@ Theme-aware, responsive scrollspy component.
Theme-aware, responsive sidebar component.
- Mount: `data-component="Sidebar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""`
- Slots: `default`
- Events: `toggle`, `open`, `close`
- Events: `toggle`, `open`, `close`, `select`
### Stepper
@@ -741,9 +788,9 @@ Theme-aware, responsive drawer component.
Theme-aware, responsive dropdown component.
- Mount: `data-component="Dropdown"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Dropdown"`, `description: string = ""`, `open: boolean = false`, `placement: string = "bottom"`, `closeLabel: string = "Close"`, `class: string = ""`
- Slots: `default`
- Events: `open`, `close`, `select`, `change`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Open menu"`, `items: string = []`, `placement: string = "end"`, `class: string = ""`
- Slots: `trigger`, `header`, `footer`
- Events: `toggle`, `open`, `close`, `select`
### Modal
+25
View File
@@ -191,6 +191,11 @@
"category": "navigation",
"purpose": "Theme-aware, responsive navbar component."
},
{
"name": "Footer",
"category": "navigation",
"purpose": "Responsive application footer with structured links and pre/post content slots."
},
{
"name": "MegaMenu",
"category": "navigation",
@@ -429,6 +434,26 @@
"name": "WysiwygEditor",
"category": "integrations",
"purpose": "Theme-aware, responsive wysiwyg editor component."
},
{
"name": "PreferenceSwitcher",
"category": "navigation",
"purpose": "Accessible theme, accent color, and language preference controls."
},
{
"name": "PortalDashboard",
"category": "blocks",
"purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates."
},
{
"name": "AuthSplitLayout",
"category": "layout",
"purpose": "Responsive 50/50 authentication layout with content and form regions."
},
{
"name": "AuthForm",
"category": "forms",
"purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA."
}
]
}
+424 -27
View File
@@ -1,6 +1,6 @@
{
"generatedFrom": "packages/ui/components/*.wrn",
"count": 85,
"count": 90,
"components": [
{
"name": "Accordion",
@@ -675,6 +675,154 @@
"events": ["dismiss", "action"],
"source": "components/alert.wrn"
},
{
"name": "AuthForm",
"mount": "AuthForm",
"category": "forms",
"purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "mode",
"type": "string",
"required": false,
"default": "\"sign-in\""
},
{
"name": "action",
"type": "string",
"required": false,
"default": "\"/api/auth/login\""
},
{
"name": "method",
"type": "string",
"required": false,
"default": "\"post\""
},
{
"name": "title",
"type": "string",
"required": false,
"default": "\"Sign in\""
},
{
"name": "description",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "returnTo",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "schema",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "showRemember",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "showName",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "submitLabel",
"type": "string",
"required": false,
"default": "\"Continue\""
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["default"],
"events": ["submit", "change", "input", "focus", "blur"],
"source": "components/AuthForm.wrn"
},
{
"name": "AuthSplitLayout",
"mount": "AuthSplitLayout",
"category": "layout",
"purpose": "Responsive 50/50 authentication layout with content and form regions.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "eyebrow",
"type": "string",
"required": false,
"default": "\"Secure identity\""
},
{
"name": "title",
"type": "string",
"required": false,
"default": "\"Welcome back\""
},
{
"name": "description",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "brand",
"type": "string",
"required": false,
"default": "\"Police Management System\""
},
{
"name": "features",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["aside-extra", "form"],
"events": [],
"source": "components/AuthSplitLayout.wrn"
},
{
"name": "Avatar",
"mount": "Avatar",
@@ -2062,7 +2210,7 @@
"default": "\"\""
}
],
"slots": [],
"slots": ["default"],
"events": ["input", "change", "focus", "blur", "invalid"],
"source": "components/Checkbox.wrn"
},
@@ -3436,34 +3584,22 @@
"default": "\"primary\""
},
{
"name": "title",
"name": "label",
"type": "string",
"required": false,
"default": "\"Dropdown\""
"default": "\"Open menu\""
},
{
"name": "description",
"name": "items",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "open",
"type": "boolean",
"required": false,
"default": "false"
"default": "[]"
},
{
"name": "placement",
"type": "string",
"required": false,
"default": "\"bottom\""
},
{
"name": "closeLabel",
"type": "string",
"required": false,
"default": "\"Close\""
"default": "\"end\""
},
{
"name": "class",
@@ -3472,8 +3608,8 @@
"default": "\"\""
}
],
"slots": ["default"],
"events": ["open", "close", "select", "change"],
"slots": ["trigger", "header", "footer"],
"events": ["toggle", "open", "close", "select"],
"source": "components/Dropdown.wrn"
},
{
@@ -3755,6 +3891,65 @@
"events": ["cancel", "retry", "complete"],
"source": "components/FileUploadProgress.wrn"
},
{
"name": "Footer",
"mount": "Footer",
"category": "navigation",
"purpose": "Responsive application footer with structured links and pre/post content slots.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "label",
"type": "string",
"required": false,
"default": "\"Footer navigation\""
},
{
"name": "items",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "columns",
"type": "number",
"required": false,
"default": "3"
},
{
"name": "maxWidth",
"type": "string",
"required": false,
"default": "\"compact\""
},
{
"name": "copyright",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["pre-footer", "post-footer", "copyright-left", "copyright-right"],
"events": ["select", "action"],
"source": "components/Footer.wrn"
},
{
"name": "Grid",
"mount": "Grid",
@@ -5039,7 +5234,19 @@
"name": "label",
"type": "string",
"required": false,
"default": "\"Navbar\""
"default": "\"Primary navigation\""
},
{
"name": "topbarLabel",
"type": "string",
"required": false,
"default": "\"Utility navigation\""
},
{
"name": "brand",
"type": "string",
"required": false,
"default": "{}"
},
{
"name": "items",
@@ -5047,6 +5254,12 @@
"required": false,
"default": "[]"
},
{
"name": "actions",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "active",
"type": "string",
@@ -5054,10 +5267,28 @@
"default": "\"\""
},
{
"name": "orientation",
"name": "sticky",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "openOnHover",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "maxWidth",
"type": "string",
"required": false,
"default": "\"horizontal\""
"default": "\"full\""
},
{
"name": "mobileLabel",
"type": "string",
"required": false,
"default": "\"Toggle navigation\""
},
{
"name": "class",
@@ -5066,8 +5297,8 @@
"default": "\"\""
}
],
"slots": ["default"],
"events": ["toggle", "open", "close"],
"slots": ["topbar", "actions"],
"events": ["toggle", "open", "close", "select", "action"],
"source": "components/Navbar.wrn"
},
{
@@ -5349,6 +5580,166 @@
"events": ["open", "close"],
"source": "components/Popover.wrn"
},
{
"name": "PortalDashboard",
"mount": "PortalDashboard",
"category": "blocks",
"purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "eyebrow",
"type": "string",
"required": false,
"default": "\"Overview\""
},
{
"name": "eyebrowKey",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "title",
"type": "string",
"required": false,
"default": "\"Dashboard\""
},
{
"name": "titleKey",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "description",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "descriptionKey",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "userName",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "metrics",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "actions",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "updates",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "tasks",
"type": "string",
"required": false,
"default": "[]"
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": ["hero-action"],
"events": ["action", "navigate"],
"source": "components/PortalDashboard.wrn"
},
{
"name": "PreferenceSwitcher",
"mount": "PreferenceSwitcher",
"category": "navigation",
"purpose": "Accessible theme, accent color, and language preference controls.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\""
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\""
},
{
"name": "themeLabel",
"type": "string",
"required": false,
"default": "\"Theme\""
},
{
"name": "colorLabel",
"type": "string",
"required": false,
"default": "\"Accent color\""
},
{
"name": "languageLabel",
"type": "string",
"required": false,
"default": "\"Language\""
},
{
"name": "languages",
"type": "string",
"required": false,
"default": "["
},
{
"name": "colors",
"type": "string",
"required": false,
"default": "["
},
{
"name": "compact",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
}
],
"slots": [],
"events": ["theme", "color", "language"],
"source": "components/PreferenceSwitcher.wrn"
},
{
"name": "Progress",
"mount": "Progress",
@@ -6110,6 +6501,12 @@
"required": false,
"default": "\"horizontal\""
},
{
"name": "mobileLabel",
"type": "string",
"required": false,
"default": "\"Open navigation\""
},
{
"name": "class",
"type": "string",
@@ -6118,7 +6515,7 @@
}
],
"slots": ["default"],
"events": ["toggle", "open", "close"],
"events": ["toggle", "open", "close", "select"],
"source": "components/Sidebar.wrn"
},
{
+84
View File
@@ -0,0 +1,84 @@
component AuthForm {
props {
@event submit = function
@event change = function
@event input = function
@event focus = function
@event blur = function
size = "default"
color = "primary"
mode = "sign-in"
action = "/api/auth/login"
method = "post"
title = "Sign in"
description = ""
returnTo = ""
schema = ""
showRemember = true
showName = true
submitLabel = "Continue"
class = ""
}
functions {
function schemaName() {
if (schema) return schema
if (mode === "sign-in") return "auth-login"
if (mode === "register") return "auth-register"
if (mode === "recover") return "auth-password-request"
if (mode === "reset") return "auth-password-reset"
if (mode === "mfa") return "auth-mfa"
return "auth-empty"
}
function submitForm(event) {
$emit("submit", { event: event, mode: mode, action: action })
}
function fieldEvent(type, event) {
const detail = event.detail || {}
$emit(type, { event: event, name: detail.name || "", value: detail.value || "" })
}
}
view {
<section class="wire-auth-form wire-next--color-{color} wire-next--size-{size} {class}">
<header>
<span class="wire-auth-form__icon icon-[lucide--fingerprint]" aria-hidden="true"></span>
<div><h2>{title}</h2>{#if description}<p>{description}</p>{/if}</div>
</header>
<form
action="{action}"
method="{method}"
data-schema="{schemaName()}"
data-wrnexus-runtime="auth"
novalidate="true"
@submit="submitForm($event)"
>
{#if returnTo}<input type="hidden" name="returnTo" value="{returnTo}" />{/if}
{#if mode === "register" && showName}
<Input label="Full name" name="displayName" autocomplete="name" placeholder="Enter your full name" required="true" icon="icon-[lucide--user-round]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
{/if}
{#if mode === "sign-in" || mode === "register" || mode === "recover"}
<Input label="{mode === 'recover' ? 'Registered email or mobile' : 'Email, mobile, or username'}" name="{mode === 'recover' ? 'identifier' : mode === 'register' ? 'email' : 'identifier'}" type="{mode === 'register' ? 'email' : 'text'}" autocomplete="{mode === 'register' ? 'email' : 'username'}" placeholder="{mode === 'recover' ? 'Enter your registered identity' : 'Enter your identity'}" required="true" icon="icon-[lucide--at-sign]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
{/if}
{#if mode === "sign-in" || mode === "register" || mode === "reset"}
<Input label="{mode === 'reset' ? 'New password' : 'Password'}" name="password" type="password" autocomplete="{mode === 'sign-in' ? 'current-password' : 'new-password'}" placeholder="Enter your password" required="true" icon="icon-[lucide--lock-keyhole]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
{/if}
{#if mode === "register" || mode === "reset"}
<Input label="Confirm password" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Enter the password again" required="true" icon="icon-[lucide--shield-check]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
{/if}
{#if mode === "mfa"}
<PinInput label="Verification code" name="code" length={6} inputMode="numeric" />
{/if}
{#if mode === "sign-in" && showRemember}
<div class="wire-auth-form__options">
<Checkbox label="Remember this device" name="rememberDevice" value="on" />
<a href="/forgot-password">Forgot password?</a>
</div>
{/if}
<Button type="submit" label="{submitLabel}" variant="default" color="{color}" size="lg" fullWidth="true" icon="icon-[lucide--arrow-right]" iconPosition="end" class="wire-auth-form__submit" />
</form>
<slot />
</section>
}
}
@@ -0,0 +1,83 @@
component AuthSplitLayout {
props {
size = "default"
color = "primary"
eyebrow = "Secure identity"
title = "Welcome back"
description = ""
brand = "Police Management System"
features = []
class = ""
}
view {
<main
class="wire-auth-split wire-next--color-{color} wire-next--size-{size} {class}"
>
<section
class="wire-auth-split__content"
>
<div
class="wire-auth-split__brand"
>
<span
class="icon-[lucide--shield-check]"
aria-hidden="true"
>
</span>
<span><strong>{brand}</strong><small>Unified identity and access</small></span>
</div>
<div
class="wire-auth-split__message"
>
<span
class="wire-auth-split__eyebrow"
>
{eyebrow}
</span>
<h1>{title}</h1>
{#if description}
<p>{description}</p>
{/if}
<div
class="wire-auth-split__features"
>
{#each features as item}
<article>
<span
class="{item.icon || 'icon-[lucide--check-circle-2]'}"
aria-hidden="true"
>
</span>
<span><strong>{item.label}</strong>
{#if item.description}
<small>{item.description}</small>
{/if}
</span>
</article>
{/each}
</div>
<slot
name="aside-extra"
/>
</div>
<small
class="wire-auth-split__legal"
>
Authorised access only · Activity may be monitored and audited.
</small>
</section>
<section
class="wire-auth-split__form"
>
<div
class="wire-auth-split__form-inner"
>
<slot
name="form"
/>
</div>
</section>
</main>
}
}
+40 -10
View File
@@ -53,7 +53,9 @@ component AvatarGroup {
role="group"
aria-label="Avatar group"
>
<div class="wire-next__avatar-group-members">
<div
class="wire-next__avatar-group-members"
>
{#each visibleMembers() as item}
<span
class="wire-next__avatar-group-member"
@@ -64,18 +66,35 @@ component AvatarGroup {
tabindex="{showTooltips && (item.tooltip || item.name) ? '0' : '-1'}"
aria-label="{item.name || item.alt || item.initials || 'Group member'}"
>
<span class="wire-next__avatar-group-avatar">
<span
class="wire-next__avatar-group-avatar"
>
{#if item.src}
<img src="{item.src}" alt="{item.alt || item.name || ''}" loading="lazy" />
<img
src="{item.src}"
alt="{item.alt || item.name || ''}"
loading="lazy"
/>
{:else if item.initials}
<span aria-hidden="true">{item.initials}</span>
<span
aria-hidden="true"
>
{item.initials}
</span>
{:else}
<span class="icon-[lucide--user-round]" aria-hidden="true"></span>
<span
class="icon-[lucide--user-round]"
aria-hidden="true"
>
</span>
{/if}
</span>
{#if showTooltips && (item.tooltip || item.name)}
<span class="wire-next__avatar-group-tooltip" role="tooltip">
<span
class="wire-next__avatar-group-tooltip"
role="tooltip"
>
{item.tooltip || item.name}
</span>
{/if}
@@ -83,7 +102,9 @@ component AvatarGroup {
{/each}
{#if hiddenMembers().length > 0}
<span class="wire-next__avatar-group-overflow">
<span
class="wire-next__avatar-group-overflow"
>
<button
type="button"
class="wire-next__avatar-group-overflow-button"
@@ -101,10 +122,19 @@ component AvatarGroup {
aria-hidden="{overflowOpen ? 'false' : 'true'}"
>
{#each hiddenMembers() as item}
<span class="wire-next__avatar-group-menu-item" role="menuitem">
<span class="wire-next__avatar-group-menu-avatar">
<span
class="wire-next__avatar-group-menu-item"
role="menuitem"
>
<span
class="wire-next__avatar-group-menu-avatar"
>
{#if item.src}
<img src="{item.src}" alt="" loading="lazy" />
<img
src="{item.src}"
alt=""
loading="lazy"
/>
{:else}
<span>{item.initials || '?'}</span>
{/if}
+41 -11
View File
@@ -1,23 +1,53 @@
component Dropdown {
props {
@event toggle = function
@event open = function
@event close = function
@event select = function
@event change = function
size = "default"
color = "primary"
title = "Dropdown"
description = ""
open = false
placement = "bottom"
closeLabel = "Close"
label = "Open menu"
items = []
placement = "end"
class = ""
}
functions {
function toggleMenu(event) {
$emit("toggle", { open: event.currentTarget.open })
$emit(event.currentTarget.open ? "open" : "close", { source: "trigger" })
}
function selectItem(item) {
$emit("select", { item: item, value: item.value || "" })
}
}
view {
<div data-show="{open}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--dropdown wire-next--placement-{placement} {class}" role="region" aria-modal="false" aria-label="{title}">
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header>
{#if description}<p>{description}</p>{/if}
<slot />
</div>
<details class="wire-dropdown wire-dropdown--{placement} wire-next--color-{color} wire-next--size-{size} {class}" data-wrn-dropdown @toggle="toggleMenu($event)">
<summary aria-label="{label}">
<slot name="trigger" />
<span class="wire-dropdown__chevron icon-[lucide--chevron-down]" aria-hidden="true"></span>
</summary>
<div class="wire-dropdown__panel" role="menu" aria-label="{label}">
<slot name="header" />
{#each items as item}
{#if item.type === "divider"}
<hr />
{:else}
{#if item.type === "header"}
<span class="wire-dropdown__heading">{item.label}</span>
{:else}
<a class="wire-dropdown__item {item.danger ? 'wire-dropdown__item--danger' : ''}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" role="menuitem" @click="selectItem(item)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span><strong>{item.label}</strong>{#if item.description}<small>{item.description}</small>{/if}</span>
{#if item.external}<span class="icon-[lucide--arrow-up-right]" aria-hidden="true"></span>{/if}
</a>
{/if}
{/if}
{/each}
<slot name="footer" />
</div>
</details>
}
}
+101
View File
@@ -0,0 +1,101 @@
component Footer {
props {
@event select = function
@event action = function
size = "default"
color = "primary"
label = "Footer navigation"
items = []
columns = 3
maxWidth = "compact"
copyright = ""
class = ""
}
functions {
function groupedItems() {
const groups = []
let group = null
items.forEach((item) => {
if (item.type === "header") {
group = {
heading: item,
links: []
}
groups.push(group)
return
}
if (!group) {
group = {
heading: null,
links: []
}
groups.push(group)
}
group.links.push(item)
})
return groups
}
function selectItem(item) {
$emit(item.type === "action" ? "action" : "select", {
item: item,
value: item.value || ""
})
}
}
view {
<footer class="wire-footer wire-footer--width-{maxWidth} wire-next--color-{color} wire-next--size-{size} wire-footer--columns-{columns} {class}">
<div class="wire-footer__grid">
<div class="wire-footer__pre">
<slot name="pre-footer" />
</div>
<nav class="wire-footer__links" aria-label="{label}">
{#each groupedItems() as group}
<section class="wire-footer__column">
{#if group.heading}
<strong class="wire-footer__heading">{group.heading.label}</strong>
{/if}
<div class="wire-footer__column-links">
{#each group.links as item}
<a class="wire-footer__link wire-footer__link--{item.type || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectItem(item)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
{#if item.external}<span aria-hidden="true">↗</span>{/if}
</a>
{/each}
</div>
</section>
{/each}
</nav>
<div class="wire-footer__post">
<slot name="post-footer" />
</div>
</div>
{#if copyright}
<div class="wire-footer__bottom">
<div class="wire-footer__copyright-left">
<slot name="copyright-left" />
</div>
<div class="wire-footer__copyright">{copyright}</div>
<div class="wire-footer__copyright-right">
<slot name="copyright-right" />
</div>
</div>
{:else}
<div class="wire-footer__bottom wire-footer__bottom--slots">
<div class="wire-footer__copyright-left">
<slot name="copyright-left" />
</div>
<div class="wire-footer__copyright-right">
<slot name="copyright-right" />
</div>
</div>
{/if}
</footer>
}
}
+126 -6
View File
@@ -3,18 +3,138 @@ component Navbar {
@event toggle = function
@event open = function
@event close = function
@event select = function
@event action = function
size = "default"
color = "primary"
label = "Navbar"
label = "Primary navigation"
topbarLabel = "Utility navigation"
brand = {}
items = []
actions = []
active = ""
orientation = "horizontal"
sticky = false
openOnHover = false
maxWidth = "full"
mobileLabel = "Toggle navigation"
class = ""
}
state mobileOpen = false
functions {
function toggleNavigation() {
mobileOpen = !mobileOpen
$emit("toggle", { open: mobileOpen })
$emit(mobileOpen ? "open" : "close", { source: "mobile" })
}
function selectItem(item, level) {
mobileOpen = false
$emit("select", { item: item, value: item.value || "", level: level })
}
function selectAction(item) {
mobileOpen = false
$emit("action", { item: item, value: item.value || "" })
}
function isItemActive(item) {
if (!item) return false
if (item.value && item.value === active) return true
if (!item.children || !item.children.length) return false
return item.children.some(function (child) {
return isItemActive(child)
})
}
function toggleDropdown(event, item) {
$emit(event.currentTarget.open ? "open" : "close", {
source: "menu",
item: item
})
}
}
view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--navbar wire-next--{orientation} {class}" aria-label="{label}">
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
<slot />
</nav>
<header class="wire-navbar wire-navbar--width-{maxWidth} wire-next--color-{color} wire-next--size-{size} {sticky ? 'wire-navbar--sticky' : ''} {class}" data-wrn-navbar data-open-on-hover="{openOnHover}">
<div class="wire-navbar__topbar" aria-label="{topbarLabel}">
<slot name="topbar" />
</div>
<div class="wire-navbar__main">
<a class="wire-navbar__brand" href="{brand.href || '/'}" aria-label="{brand.ariaLabel || brand.label || label}">
{#if brand.logo}
<img class="wire-navbar__brand-logo" src="{brand.logo}" alt="{brand.logoAlt || brand.label || ''}" width="{brand.logoWidth || ''}" height="{brand.logoHeight || ''}" />
{/if}
{#if brand.icon}
<span class="wire-navbar__brand-icon {brand.icon}" aria-hidden="true"></span>
{/if}
{#if brand.label || brand.description}
<span class="wire-navbar__brand-copy">
{#if brand.label}<strong>{brand.label}</strong>{/if}
{#if brand.description}<small>{brand.description}</small>{/if}
</span>
{/if}
</a>
<button type="button" class="wire-navbar__toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleNavigation()">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
<nav class="wire-navbar__menus" aria-label="{label}">
{#each items as item}
{#if item.children && item.children.length}
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown($event, item)">
<summary aria-current="{isItemActive(item) ? 'page' : ''}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
<span class="wire-navbar__chevron" aria-hidden="true"></span>
</summary>
<div class="wire-navbar__panel wire-navbar__panel--columns-{item.columns || 1}">
{#if item.description}<p class="wire-navbar__panel-intro">{item.description}</p>{/if}
{#each item.children as child}
<div class="wire-navbar__group">
{#if child.children && child.children.length}
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
{#if child.description}<small>{child.description}</small>{/if}
{#each child.children as nested}
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)">
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
</a>
{/each}
{:else}
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
</a>
{/if}
</div>
{/each}
</div>
</details>
{:else}
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
{/if}
{/each}
</nav>
<div class="wire-navbar__actions">
{#each actions as item}
<a class="wire-navbar__action wire-navbar__action--{item.variant || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectAction(item)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
{/each}
<slot name="actions" />
</div>
</div>
</div>
</header>
}
}
@@ -0,0 +1,98 @@
component PortalDashboard {
props {
@event action = function
@event navigate = function
size = "default"
color = "primary"
eyebrow = "Overview"
eyebrowKey = ""
title = "Dashboard"
titleKey = ""
description = ""
descriptionKey = ""
userName = ""
metrics = []
actions = []
updates = []
tasks = []
class = ""
}
functions {
function chooseAction(item, index) {
$emit("action", { item: item, value: item.value || "", index: index })
}
function chooseNavigation(item, index) {
$emit("navigate", { item: item, value: item.value || "", index: index })
}
}
view {
<main class="wire-portal-dashboard wire-next--color-{color} wire-next--size-{size} {class}">
<header class="wire-portal-dashboard__hero">
<div>
{#if eyebrowKey}<span class="wire-portal-dashboard__eyebrow" data-t="{eyebrowKey}">{eyebrow}</span>{/if}
{#if !eyebrowKey}<span class="wire-portal-dashboard__eyebrow">{eyebrow}</span>{/if}
<h1>
{#if titleKey}<span data-t="{titleKey}">{title}</span>{/if}
{#if !titleKey}<span>{title}</span>{/if}
{#if userName}, {userName}{/if}
</h1>
{#if descriptionKey}<p data-t="{descriptionKey}">{description}</p>{/if}
{#if !descriptionKey && description}<p>{description}</p>{/if}
</div>
<div class="wire-portal-dashboard__hero-slot"><slot name="hero-action" /></div>
</header>
<section class="wire-portal-dashboard__metrics" aria-label="Summary">
{#each metrics as item}
<article class="wire-portal-dashboard__metric">
<span class="wire-portal-dashboard__metric-icon {item.icon || 'icon-[lucide--activity]'}" aria-hidden="true"></span>
<div><small>{item.label}</small><strong>{item.value}</strong>{#if item.detail}<span>{item.detail}</span>{/if}</div>
</article>
{/each}
</section>
<div class="wire-portal-dashboard__grid">
<section class="wire-portal-dashboard__panel wire-portal-dashboard__panel--actions">
<div class="wire-portal-dashboard__panel-heading"><div><small>Shortcuts</small><h2>Quick actions</h2></div></div>
<div class="wire-portal-dashboard__actions">
{#each actions as item, index}
<a href="{item.href || '#'}" @click="chooseAction(item, index)">
<span class="{item.icon || 'icon-[lucide--arrow-up-right]'}" aria-hidden="true"></span>
<span><strong>{item.label}</strong>{#if item.description}<small>{item.description}</small>{/if}</span>
<span class="icon-[lucide--chevron-right]" aria-hidden="true"></span>
</a>
{/each}
</div>
</section>
<section class="wire-portal-dashboard__panel">
<div class="wire-portal-dashboard__panel-heading"><div><small>Priority</small><h2>Tasks requiring attention</h2></div></div>
<div class="wire-portal-dashboard__list">
{#each tasks as item, index}
<a href="{item.href || '#'}" @click="chooseNavigation(item, index)">
<span class="wire-portal-dashboard__status wire-portal-dashboard__status--{item.status || 'default'}"></span>
<span><strong>{item.label}</strong>{#if item.detail}<small>{item.detail}</small>{/if}</span>
{#if item.meta}<em>{item.meta}</em>{/if}
</a>
{/each}
</div>
</section>
<section class="wire-portal-dashboard__panel wire-portal-dashboard__panel--updates">
<div class="wire-portal-dashboard__panel-heading"><div><small>Latest</small><h2>Recent updates</h2></div></div>
<div class="wire-portal-dashboard__timeline">
{#each updates as item}
<article>
<span class="{item.icon || 'icon-[lucide--bell]'}" aria-hidden="true"></span>
<div><strong>{item.label}</strong>{#if item.detail}<p>{item.detail}</p>{/if}<small>{item.time || ''}</small></div>
</article>
{/each}
</div>
</section>
</div>
</main>
}
}
@@ -0,0 +1,82 @@
component PreferenceSwitcher {
props {
@event theme = function
@event color = function
@event language = function
size = "default"
color = "primary"
themeLabel = "Theme"
colorLabel = "Accent color"
languageLabel = "Language"
languages = [
{"label": "English", "shortLabel": "EN", "value": "en"},
{"label": "हिन्दी", "shortLabel": "हि", "value": "hi"},
{"label": "मराठी", "shortLabel": "म", "value": "mr"}
]
colors = [
{"label": "Blue", "value": "blue", "hex": "#2563eb"},
{"label": "Cyan", "value": "cyan", "hex": "#0891b2"},
{"label": "Emerald", "value": "emerald", "hex": "#059669"},
{"label": "Violet", "value": "violet", "hex": "#7c3aed"},
{"label": "Rose", "value": "rose", "hex": "#e11d48"},
{"label": "Amber", "value": "amber", "hex": "#d97706"}
]
compact = true
class = ""
}
functions {
function choose(type, value) {
$emit(type, { value: value })
}
}
view {
<div class="wire-preferences wire-next--color-{color} wire-next--size-{size} {compact ? 'wire-preferences--compact' : ''} {class}" data-wrn-preferences aria-label="Display and language preferences">
<details class="wire-preferences__menu" name="wire-preference-menu">
<summary aria-label="{themeLabel}" title="{themeLabel}">
<span class="icon-[lucide--sun-moon]" aria-hidden="true"></span>
{#if !compact}<span>{themeLabel}</span>{/if}
</summary>
<div class="wire-preferences__panel">
<strong>{themeLabel}</strong>
<div class="wire-preferences__options">
<button type="button" data-wire-theme-set="light" @click="choose('theme', 'light')"><span class="icon-[lucide--sun]" aria-hidden="true"></span>Light</button>
<button type="button" data-wire-theme-set="dark" @click="choose('theme', 'dark')"><span class="icon-[lucide--moon]" aria-hidden="true"></span>Dark</button>
</div>
</div>
</details>
<details class="wire-preferences__menu" name="wire-preference-menu">
<summary aria-label="{colorLabel}" title="{colorLabel}">
<span class="icon-[lucide--palette]" aria-hidden="true"></span>
{#if !compact}<span>{colorLabel}</span>{/if}
</summary>
<div class="wire-preferences__panel wire-preferences__panel--colors">
<strong>{colorLabel}</strong>
<div class="wire-preferences__swatches">
{#each colors as item}
<button type="button" data-wire-accent-set="{item.value}" aria-label="{item.label}" title="{item.label}" style="--wire-preference-swatch: {item.hex}" @click="choose('color', item.value)"></button>
{/each}
</div>
</div>
</details>
<details class="wire-preferences__menu wire-preferences__menu--language" name="wire-preference-menu">
<summary aria-label="{languageLabel}" title="{languageLabel}">
<span class="icon-[lucide--languages]" aria-hidden="true"></span>
<span class="wire-preferences__current-language" aria-hidden="true">EN</span>
{#if !compact}<span>{languageLabel}</span>{/if}
</summary>
<div class="wire-preferences__panel wire-preferences__panel--language">
<strong>{languageLabel}</strong>
{#each languages as item}
<button type="button" data-wire-lang-set="{item.value}" lang="{item.value}" @click="choose('language', item.value)">
<span>{item.shortLabel}</span>{item.label}
</button>
{/each}
</div>
</details>
</div>
}
}

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