- );
-}
-
/** 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 (
+