Files
PiCloud/dashboard/src/lib/route-utils.ts
MechaCat02 a393f11344 feat(dashboard): auto-slug app names and infer route host kind from input
Two related polish passes on forms the operator hits most.

App create form: the slug field used to come before the name field and
demanded the operator hand-roll a valid slug. Now the name field comes
first and the slug is derived from it live, GitLab-style — Unicode
NFKD-decomposed, combining marks stripped (so `Café` → `cafe`), `ß`
mapped to `ss`, non-`[a-z0-9]` runs collapsed to `-`, trimmed and capped
at the backend's 63-char limit. The auto-sync releases as soon as the
operator edits the slug manually, and re-engages if they clear it. The
slug input itself runs every keystroke and paste through the same
normalizer, so dirty input never reaches the form state.

Route create form: the three-way host-kind `<select>` plus a sometimes-
disabled input was confusing — operators routinely picked the wrong
kind, typed a host the app didn't claim, and only saw the error after
hitting Create. Replace with a single text input that infers the kind
from what's there (`*` → any, `*.foo.com` → wildcard, `foo.com` →
strict), shows the detected kind as a colored chip beside the field, and
suggests the app's existing domain claims via a `<datalist>`. The same
matching logic the backend runs in `validate_route_host_against_app`
now lives in `route-utils.ts` so the form can surface a soft "not
covered by any claim" warning *before* submit. Path also pre-fills to
`/` so the most common case is one click away.

Lockfile drift from `npm install` (pre-existing 0.5.0 → 0.5.1 version
sync, npm metadata cleanup) is folded in here since it surfaced during
this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:20 +02:00

127 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AppDomain, HostKind, PathKind } from './api';
/** Guess a path kind from the literal user input. The dashboard pre-fills
* the kind selector but the user can override (the backend trusts the
* selection — selecting `exact` for `/greet/:name` matches it literally). */
export function guessPathKind(raw: string): PathKind {
if (raw.endsWith('/*')) return 'prefix';
if (/(^|\/):[a-zA-Z_]/.test(raw)) return 'param';
return 'exact';
}
/** Guess a host kind. Empty → any, leading "*." → wildcard, else strict. */
export function guessHostKind(raw: string): HostKind {
if (!raw.trim()) return 'any';
if (raw.startsWith('*.')) return 'wildcard';
return 'strict';
}
/** Warn-not-block: does the user's selection match what we'd guess
* from the input? Used by the routing form for the soft mismatch
* hint. Returns null when no warning is needed. */
export function pathKindMismatchWarning(raw: string, kind: PathKind): string | null {
const guessed = guessPathKind(raw);
if (guessed === kind) return null;
const hint =
guessed === 'param'
? 'this looks like a param pattern (contains `:name`)'
: guessed === 'prefix'
? 'this looks like a prefix pattern (ends with `/*`)'
: 'this looks like a literal path';
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
}
/** Parse the user's free-text host input into the (kind, stored host)
* pair the API expects. Mirrors the dashboard's pre-existing storage
* convention: wildcard route hosts are stored as the bare suffix
* (`*.foo.com` → `foo.com`); display-time formatting re-adds the `*.`. */
export interface ParsedHostInput {
kind: HostKind;
/** What gets sent in the API payload as `host`. */
host: string;
/** Canonical display form, for chips/labels. */
display: string;
}
export function parseHostInput(raw: string): ParsedHostInput {
const trimmed = raw.trim();
if (trimmed === '' || trimmed === '*') {
return { kind: 'any', host: '', display: '*' };
}
if (trimmed.startsWith('*.')) {
const suffix = trimmed.slice(2);
return { kind: 'wildcard', host: suffix, display: `*.${suffix}` };
}
return { kind: 'strict', host: trimmed, display: trimmed };
}
/** Frontend mirror of `validate_route_host_against_app` in
* crates/manager-core/src/route_admin.rs. Lets us surface a live
* warning instead of waiting for a 422. The server runs the same
* check on submit — this is purely an early signal. */
export type HostClaimCheck =
| { ok: true; matched: string }
| { ok: false; reason: string };
export function checkHostAgainstClaims(
parsed: ParsedHostInput,
claims: AppDomain[]
): HostClaimCheck {
if (parsed.kind === 'any') return { ok: true, matched: '*' };
if (claims.length === 0) {
return {
ok: false,
reason: 'this app has no domain claims yet — add one in the apps Domains tab'
};
}
const hostLower = parsed.host.toLowerCase();
for (const claim of claims) {
const claimLower = claim.pattern.toLowerCase();
const claimSuffix = claimLower.split('.').slice(1).join('.');
if (parsed.kind === 'strict') {
if (claim.shape === 'exact' && hostLower === claimLower) {
return { ok: true, matched: claim.pattern };
}
if (
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
claimSuffix &&
hostLower.endsWith(`.${claimSuffix}`)
) {
return { ok: true, matched: claim.pattern };
}
} else {
// wildcard route
if (
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
claimSuffix === hostLower
) {
return { ok: true, matched: claim.pattern };
}
}
}
return {
ok: false,
reason: `${parsed.display} is not covered by any of this apps claims`
};
}
/** Suggestion strings for the host input's <datalist>. We always
* include the wildcard `*` (any host) as the first option, then the
* app's claims rendered in the form the route input expects. */
export function hostSuggestions(claims: AppDomain[]): string[] {
const items: string[] = ['*'];
for (const claim of claims) {
if (claim.shape === 'exact') {
items.push(claim.pattern);
} else {
// Both wildcard and parameterized claims are usable as a
// wildcard route. Render as `*.suffix` — we can't preserve
// the {param} binding name through the current route form.
const suffix = claim.pattern.split('.').slice(1).join('.');
if (suffix) items.push(`*.${suffix}`);
}
}
// Dedupe while preserving order.
return [...new Set(items)];
}