diff --git a/client/spaces_ui/components/SpaceForm.tsx b/client/spaces_ui/components/SpaceForm.tsx index 65928cfb..0fe3dcfa 100644 --- a/client/spaces_ui/components/SpaceForm.tsx +++ b/client/spaces_ui/components/SpaceForm.tsx @@ -7,14 +7,11 @@ import { Checkbox, Input, Select, + UrlPrefixInput, } from "@silverbulletmd/silverbullet/ui"; import { adminApi, listUsers } from "../api.ts"; import { FolderPicker } from "../FolderPicker.tsx"; -import { - FieldErrors, - UrlPrefixInput, - useSlugDefaults, -} from "../space_fields.tsx"; +import { FieldErrors, useSlugDefaults } from "../space_fields.tsx"; import type { Binding, FieldError, SpaceInfo, UserInfo } from "../types.ts"; export function SpaceForm({ @@ -232,6 +229,7 @@ export function SpaceForm({ {bindType === "prefix" ? ( diff --git a/client/spaces_ui/components/wizard/SpaceStep.tsx b/client/spaces_ui/components/wizard/SpaceStep.tsx index 392aac15..3c0f3306 100644 --- a/client/spaces_ui/components/wizard/SpaceStep.tsx +++ b/client/spaces_ui/components/wizard/SpaceStep.tsx @@ -1,7 +1,11 @@ import { Fragment } from "preact"; -import { Button, Input } from "@silverbulletmd/silverbullet/ui"; +import { + Button, + Input, + UrlPrefixInput, +} from "@silverbulletmd/silverbullet/ui"; import { FolderPicker } from "../../FolderPicker.tsx"; -import { FieldErrors, UrlPrefixInput } from "../../space_fields.tsx"; +import { FieldErrors } from "../../space_fields.tsx"; import type { FieldError } from "../../types.ts"; import { defaultFolder, @@ -84,6 +88,7 @@ export function SpaceStep({ diff --git a/client/spaces_ui/slugify.ts b/client/spaces_ui/slugify.ts deleted file mode 100644 index 8ed887c3..00000000 --- a/client/spaces_ui/slugify.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Lower-cased, filesystem-safe version of a space name, used to derive its - * default folder and URL prefix. Shared by the space manager (`SpaceForm`) and - * the first-run setup wizard (`Wizard`) — both built from `client/spaces_ui` — - * so both derive identical defaults. - */ -export function slugify(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} diff --git a/client/spaces_ui/space_fields.tsx b/client/spaces_ui/space_fields.tsx index c4ae0dca..7a51f2c3 100644 --- a/client/spaces_ui/space_fields.tsx +++ b/client/spaces_ui/space_fields.tsx @@ -1,6 +1,5 @@ import { useState } from "preact/hooks"; -import { Alert, Input } from "@silverbulletmd/silverbullet/ui"; -import { slugify } from "./slugify.ts"; +import { Alert, prefixFromName, slugify } from "@silverbulletmd/silverbullet/ui"; import type { FieldError } from "./types.ts"; /** @@ -16,7 +15,7 @@ export function useSlugDefaults(folderTemplate: (slug: string) => string) { function onNameChange(name: string) { const slug = slugify(name); - if (!prefixTouched) setPrefix(slug ? `/${slug}` : ""); + if (!prefixTouched) setPrefix(prefixFromName(name)); if (!folderTouched) setFolder(slug ? folderTemplate(slug) : ""); } @@ -36,28 +35,6 @@ export function useSlugDefaults(folderTemplate: (slug: string) => string) { }; } -/** The prefix field, decorated with the origin it will be served from. */ -export function UrlPrefixInput({ - id, - value, - onInput, -}: { - id: string; - value: string; - onInput: (v: string) => void; -}) { - return ( -
- {location.origin} - onInput(e.currentTarget.value)} - /> -
- ); -} - /** Server-returned field errors, rendered the same way on every form. */ export function FieldErrors({ errors }: { errors: FieldError[] }) { return ( diff --git a/client/spaces_ui/wizard.ts b/client/spaces_ui/wizard.ts index 46db3d1f..a393899f 100644 --- a/client/spaces_ui/wizard.ts +++ b/client/spaces_ui/wizard.ts @@ -1,4 +1,4 @@ -import { slugify } from "./slugify.ts"; +import { slugify } from "@silverbulletmd/silverbullet/ui"; import type { FieldError } from "./types.ts"; /** Where a space is bound: the server root, or a URL prefix under it. */ diff --git a/client/styles/_standalone.scss b/client/styles/_standalone.scss index 830d72c4..6b978ae6 100644 --- a/client/styles/_standalone.scss +++ b/client/styles/_standalone.scss @@ -197,56 +197,6 @@ body.sb-standalone.sb-page { flex: 1; } -/* Binding-value input dressed up as the URL it will produce: fixed - scheme/host parts as affixes around the editable segment. Shared by the - setup wizard and the Space Manager's space form. */ -// Matches .sb-input's own surface so the affixes read as decoration on one -// field rather than a differently-styled control sitting among the others. -.sb-standalone .sb-url-input { - display: flex; - align-items: stretch; - border: 0; - border-radius: 0.5em; - background: var(--text-field-background-color); - overflow: hidden; -} - -.sb-standalone .sb-url-input:focus-within { - outline: 2px solid var(--ui-accent-color); - outline-offset: -1px; -} - -.sb-standalone .sb-url-input .sb-input { - border: none; - border-radius: 0; - flex: 1; - min-width: 0; - width: auto; - background: transparent; -} - -// The affixes are the parts you cannot type in, so they get their own darker -// surface — the editable segment keeps the plain field background. Without -// that contrast the whole control reads as one uniformly grey box and it stops -// being obvious which part is the input. -.sb-standalone .sb-url-affix { - display: flex; - align-items: center; - padding: 0.3em 0.6em; - background: var(--subtle-background-color); - color: var(--subtle-color); - font-size: 0.9em; - white-space: nowrap; - user-select: none; -} - -.sb-standalone .sb-url-affix:first-child { - border-right: 1px solid #0002; -} - -.sb-standalone .sb-url-affix:last-child { - border-left: 1px solid #0002; -} /* Header: wordmark (+ admin tab bar) on the left, quiet log-out link on the right. diff --git a/client/styles/components.scss b/client/styles/components.scss index 4c560a19..ed1e436d 100644 --- a/client/styles/components.scss +++ b/client/styles/components.scss @@ -218,4 +218,57 @@ margin-left: 0.5em; font-size: 0.85em; opacity: 0.6; -} \ No newline at end of file +} + +/* Binding-value input dressed up as the URL it will produce: fixed + scheme/host parts as affixes around the editable segment. Backs the + `UrlPrefixInput` component, so it lives here rather than in the standalone + page styles — the desktop app's dashboard uses the component too, and only + links this stylesheet. */ +// Matches .sb-input's own surface so the affixes read as decoration on one +// field rather than a differently-styled control sitting among the others. +.sb-url-input { + display: flex; + align-items: stretch; + border: 0; + border-radius: 0.5em; + background: var(--text-field-background-color); + overflow: hidden; +} + +.sb-url-input:focus-within { + outline: 2px solid var(--ui-accent-color); + outline-offset: -1px; +} + +.sb-url-input .sb-input { + border: none; + border-radius: 0; + flex: 1; + min-width: 0; + width: auto; + background: transparent; +} + +// The affixes are the parts you cannot type in, so they get their own darker +// surface — the editable segment keeps the plain field background. Without +// that contrast the whole control reads as one uniformly grey box and it stops +// being obvious which part is the input. +.sb-url-affix { + display: flex; + align-items: center; + padding: 0.3em 0.6em; + background: var(--subtle-background-color); + color: var(--subtle-color); + font-size: 0.9em; + white-space: nowrap; + user-select: none; +} + +.sb-url-affix:first-child { + border-right: 1px solid #0002; +} + +.sb-url-affix:last-child { + border-left: 1px solid #0002; +} diff --git a/plug-api/ui/index.ts b/plug-api/ui/index.ts index 09f7316e..999c21ff 100644 --- a/plug-api/ui/index.ts +++ b/plug-api/ui/index.ts @@ -14,6 +14,9 @@ export { Badge } from "./badge.tsx"; export type { BadgeProps } from "./badge.tsx"; export { Progress } from "./progress.tsx"; export type { ProgressProps } from "./progress.tsx"; +export { UrlPrefixInput } from "./url_prefix_input.tsx"; +export type { UrlPrefixInputProps } from "./url_prefix_input.tsx"; +export { normalizePrefix, prefixFromName, slugify } from "./slugify.ts"; export { cx } from "./cx.ts"; export { panelStyles } from "./panel_styles.ts"; export type { PanelStylesOptions } from "./panel_styles.ts"; diff --git a/plug-api/ui/slugify.test.ts b/plug-api/ui/slugify.test.ts new file mode 100644 index 00000000..33090f9b --- /dev/null +++ b/plug-api/ui/slugify.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { normalizePrefix, prefixFromName, slugify } from "./slugify.ts"; + +test("slugify lowercases and collapses non-alphanumerics", () => { + expect(slugify("My Space!")).toBe("my-space"); + expect(slugify(" Spaced Out ")).toBe("spaced-out"); + expect(slugify("a---b")).toBe("a-b"); + expect(slugify("!!!")).toBe(""); +}); + +test("prefixFromName returns an absolute path", () => { + // The leading slash is part of the value: a binding prefix is a path, and + // the field renders it directly into the URL it shows. + expect(prefixFromName("My Space")).toBe("/my-space"); +}); + +test("prefixFromName yields empty rather than a bare slash", () => { + // "/" is a *root* binding, a meaningfully different thing from "no prefix + // chosen yet" — defaulting an empty name to it would silently bind the + // space to the server root. + expect(prefixFromName("")).toBe(""); + expect(prefixFromName(" ")).toBe(""); + expect(prefixFromName("!!!")).toBe(""); +}); + +test("normalizePrefix slugifies a value that already has a slash", () => { + // The desktop app's `provision_space` takes anything starting with "/" + // as-is, so the client is what has to guarantee a clean slug. + expect(normalizePrefix("/My Space!")).toBe("/my-space"); + expect(normalizePrefix("My Space!")).toBe("/my-space"); +}); + +test("normalizePrefix collapses repeated leading slashes", () => { + expect(normalizePrefix("//wiki")).toBe("/wiki"); + expect(normalizePrefix("/wiki")).toBe("/wiki"); +}); + +test("normalizePrefix maps a slash-only value to a root binding", () => { + expect(normalizePrefix("/")).toBe(""); + expect(normalizePrefix("")).toBe(""); +}); + +test("normalizePrefix is idempotent", () => { + const once = normalizePrefix("/My Space!"); + expect(normalizePrefix(once)).toBe(once); +}); diff --git a/plug-api/ui/slugify.ts b/plug-api/ui/slugify.ts new file mode 100644 index 00000000..d3b8c215 --- /dev/null +++ b/plug-api/ui/slugify.ts @@ -0,0 +1,44 @@ +/** + * Lower-cased, filesystem- and URL-safe version of a space name, used to + * derive its default folder and URL prefix. + * + * Mirrors the server's `slug::slugify`, so a prefix typed into the Space + * Manager, the first-run wizard, or the desktop app's space form all reach the + * server in the same shape. + */ +export function slugify(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * The default URL prefix for a space called `name`, as an absolute path. + * + * The leading slash is part of the value, not decoration: a binding prefix is + * a path, the server stores and validates it that way, and showing it in the + * field is what makes the assembled URL in `UrlPrefixInput` read correctly. An + * empty name yields `""` rather than a bare `"/"`, which would bind the space + * to the server root. + */ +export function prefixFromName(name: string): string { + const slug = slugify(name); + return slug ? `/${slug}` : ""; +} + +/** + * Force a user-typed prefix into the shape the server accepts: exactly one + * leading slash, and a slugified body. + * + * Callers that hand a prefix straight to an API need this because a value + * already starting with `/` is taken as-is by the desktop app's + * `provision_space` command — it only slugifies input that lacks the slash, so + * `/My Space!` would otherwise be sent through verbatim. Returns `""` for an + * empty or slash-only input, the server's spelling of a root binding. + */ +export function normalizePrefix(raw: string): string { + const slug = slugify(raw.replace(/^\/+/, "")); + return slug ? `/${slug}` : ""; +} diff --git a/plug-api/ui/ui.test.ts b/plug-api/ui/ui.test.ts index 2a8935ed..ab49c1d4 100644 --- a/plug-api/ui/ui.test.ts +++ b/plug-api/ui/ui.test.ts @@ -10,6 +10,7 @@ import { Progress, Select, Tabs, + UrlPrefixInput, } from "./index.ts"; test("Button primary emits both classes and merges consumer class", () => { @@ -90,3 +91,31 @@ test("Progress clamps value to a width percentage", () => { expect(render(h(Progress, { value: 2 }))).toMatch(/width:\s*100%/); expect(render(h(Progress, { value: -1 }))).toMatch(/width:\s*0%/); }); + +test("UrlPrefixInput shows the origin it is given, not the ambient one", () => { + // The desktop app configures a *remote* sync server, so the origin cannot + // be read from `location` the way the server-hosted Space Manager does. + const html = render( + h(UrlPrefixInput, { + origin: "https://sb.example.com", + value: "/notes", + onInput: () => {}, + }), + ); + expect(html).toContain("sb-url-input"); + expect(html).toContain(">https://sb.example.com"); + expect(html).toContain('value="/notes"'); +}); + +test("UrlPrefixInput trims a trailing slash off the origin", () => { + // Sync server URLs are commonly stored with one; without trimming the + // assembled URL reads "https://h//notes". + const html = render( + h(UrlPrefixInput, { + origin: "https://sb.example.com/", + value: "/notes", + onInput: () => {}, + }), + ); + expect(html).toContain(">https://sb.example.com"); +}); diff --git a/plug-api/ui/url_prefix_input.tsx b/plug-api/ui/url_prefix_input.tsx new file mode 100644 index 00000000..a8ea6e90 --- /dev/null +++ b/plug-api/ui/url_prefix_input.tsx @@ -0,0 +1,32 @@ +import { Input } from "./input.tsx"; + +export type UrlPrefixInputProps = { + id?: string; + origin: string; + value: string; + onInput: (v: string) => void; +}; + +/** + * The URL-prefix field, dressed up as the URL it will produce: a fixed origin + * affix followed by the editable path segment. + */ +export function UrlPrefixInput({ + id, + origin, + value, + onInput, +}: UrlPrefixInputProps) { + return ( +
+ {origin.replace(/\/+$/, "")} + onInput(e.currentTarget.value)} + spellcheck={false} + autocomplete="off" + /> +
+ ); +}