smoodly 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +33 -1
  2. package/dist/admin/client-ops.js +1 -1
  3. package/dist/admin/editor/EditorView.js +17 -30
  4. package/dist/admin/editor/GlobalInspector.d.ts +1 -1
  5. package/dist/admin/editor/GlobalInspector.js +3 -2
  6. package/dist/admin/editor/NoticePane.js +3 -1
  7. package/dist/admin/forms/FieldWidget.d.ts +6 -6
  8. package/dist/admin/forms/FieldWidget.js +22 -10
  9. package/dist/admin/forms/RefPicker.d.ts +11 -0
  10. package/dist/admin/forms/RefPicker.js +30 -0
  11. package/dist/admin/forms/TagsField.d.ts +10 -0
  12. package/dist/admin/forms/TagsField.js +38 -0
  13. package/dist/admin/forms/ref-picker.d.ts +25 -0
  14. package/dist/admin/forms/ref-picker.js +30 -0
  15. package/dist/admin/forms/useRefOptions.d.ts +7 -0
  16. package/dist/admin/forms/useRefOptions.js +36 -0
  17. package/dist/admin/forms/useTagSuggestions.d.ts +5 -0
  18. package/dist/admin/forms/useTagSuggestions.js +22 -0
  19. package/dist/admin/ops-impl.js +33 -4
  20. package/dist/admin/ops.d.ts +4 -0
  21. package/dist/admin/serialize.d.ts +2 -0
  22. package/dist/admin/serialize.js +1 -0
  23. package/dist/admin/shell/AdminApp.js +8 -2
  24. package/dist/admin/shell/EntriesList.js +3 -2
  25. package/dist/admin/shell/EntryForm.js +14 -28
  26. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  27. package/dist/admin/shell/ErrorPane.js +6 -4
  28. package/dist/admin/shell/GlobalFields.d.ts +3 -6
  29. package/dist/admin/shell/GlobalFields.js +1 -1
  30. package/dist/admin/shell/GlobalForm.js +8 -22
  31. package/dist/admin/shell/GlobalList.js +3 -2
  32. package/dist/admin/shell/IconRail.js +3 -2
  33. package/dist/admin/shell/PagesList.js +4 -3
  34. package/dist/admin/shell/base.d.ts +4 -1
  35. package/dist/admin/shell/base.js +15 -3
  36. package/dist/admin/shell/router.d.ts +4 -3
  37. package/dist/admin/shell/router.js +17 -14
  38. package/dist/admin/ui/Combobox.d.ts +30 -0
  39. package/dist/admin/ui/Combobox.js +89 -0
  40. package/dist/admin/ui/combo-box.d.ts +16 -0
  41. package/dist/admin/ui/combo-box.js +21 -0
  42. package/dist/admin/ui/theme.js +36 -0
  43. package/dist/admin/validate.js +39 -0
  44. package/dist/admin-path.d.ts +17 -0
  45. package/dist/admin-path.js +32 -0
  46. package/dist/config.d.ts +10 -1
  47. package/dist/config.js +14 -3
  48. package/dist/entry-store.js +1 -1
  49. package/dist/fields.d.ts +3 -0
  50. package/dist/fields.js +5 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.js +1 -0
  53. package/dist/page.js +1 -1
  54. package/dist/supabase-entry-store.js +1 -1
  55. package/dist/tags.d.ts +19 -0
  56. package/dist/tags.js +50 -0
  57. package/package.json +1 -1
@@ -1,20 +1,20 @@
1
1
  "use client";
2
2
  // packages/smoodly/src/admin/shell/router.ts
3
3
  // The admin's own router (spec 2026-09-08 client routing): the route is
4
- // the URL under BASE, held in React state and moved with pushState, so
5
- // the shell stays mounted and a click is a state change, not a document
6
- // load. The URL is the only source: the shell's first render is the
4
+ // the URL under the admin path, held in React state and moved with
5
+ // pushState, so the shell stays mounted and a click is a state change,
6
+ // not a document load. The URL is the only source: the shell's first render is the
7
7
  // session gate, so no view exists before the mount effect has read it.
8
8
  // Next's router is not involved — native pushState updates its
9
9
  // usePathname without a refetch — and the admin core stays
10
10
  // framework-agnostic.
11
11
  import { useEffect, useState } from "react";
12
- import { BASE } from "./base.js";
12
+ import { DEFAULT_ADMIN_PATH } from "../../admin-path.js";
13
13
  /** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
14
14
  * the mount itself, or a pathname outside it, → []. Segments stay as
15
15
  * written; the shell decodes the ones it uses, as it does for the
16
16
  * catch-all's params. */
17
- export function segmentsOf(pathname, base = BASE) {
17
+ export function segmentsOf(pathname, base = DEFAULT_ADMIN_PATH) {
18
18
  if (pathname !== base && !pathname.startsWith(`${base}/`))
19
19
  return [];
20
20
  return pathname.slice(base.length).split("/").filter((s) => s !== "");
@@ -40,10 +40,13 @@ export function mayLeave(guards, confirm) {
40
40
  // ── the live router ──────────────────────────────────────────────────
41
41
  const guards = new Set();
42
42
  const listeners = new Set();
43
- function currentRoute() {
44
- return { segments: segmentsOf(window.location.pathname), search: window.location.search };
43
+ function currentRoute(base) {
44
+ const { pathname, search } = window.location;
45
+ return { pathname, segments: segmentsOf(pathname, base), search };
45
46
  }
46
- const sameRoute = (a, b) => a.search === b.search && a.segments.length === b.segments.length && a.segments.every((s, i) => s === b.segments[i]);
47
+ // The segments derive from the pathname and a base that never changes
48
+ // under a mounted shell, so two strings decide sameness.
49
+ const sameRoute = (a, b) => a.pathname === b.pathname && a.search === b.search;
47
50
  /** Move to `href` in place; true when the move happened. A push asks the
48
51
  * leave guard; a replace never leaves the view, so it does not ask.
49
52
  * `force` skips the guard for a view that has just saved or deleted its
@@ -56,17 +59,17 @@ export function navigate(href, opts = {}) {
56
59
  listener();
57
60
  return true;
58
61
  }
59
- /** The current route: empty until mount, then the URL, re-read on
60
- * popstate and after navigate(). */
61
- export function useRoute() {
62
- const [route, setRoute] = useState({ segments: [], search: "" });
62
+ /** The current route under `base`: empty until mount, then the URL,
63
+ * re-read on popstate and after navigate(). */
64
+ export function useRoute(base) {
65
+ const [route, setRoute] = useState({ pathname: "", segments: [], search: "" });
63
66
  useEffect(() => {
64
- const sync = () => setRoute((prev) => { const next = currentRoute(); return sameRoute(prev, next) ? prev : next; });
67
+ const sync = () => setRoute((prev) => { const next = currentRoute(base); return sameRoute(prev, next) ? prev : next; });
65
68
  sync();
66
69
  listeners.add(sync);
67
70
  window.addEventListener("popstate", sync);
68
71
  return () => { listeners.delete(sync); window.removeEventListener("popstate", sync); };
69
- }, []);
72
+ }, [base]);
70
73
  return route;
71
74
  }
72
75
  /** Register the calling view as dirty: navigate() asks before leaving,
@@ -0,0 +1,30 @@
1
+ import { type KeyboardEvent, type ReactNode } from "react";
2
+ import { type ComboOption } from "./combo-box.ts";
3
+ export type ComboboxProps = {
4
+ options: ComboOption[];
5
+ onPick(value: string): void;
6
+ /** Accessible name of the input. */
7
+ label: string;
8
+ placeholder?: string;
9
+ disabled?: boolean;
10
+ /** Stay open with the query cleared after a pick (multi-select). */
11
+ keepOpen?: boolean;
12
+ /** Shown when there are no options at all; "No matches" covers a filter that leaves nothing. */
13
+ emptyText?: string;
14
+ renderOption?(o: ComboOption): ReactNode;
15
+ /** Free text: Enter with no active row submits the query, and no row starts active. */
16
+ onSubmit?(query: string): void;
17
+ /** With `onSubmit`: a last row, `Create "query"`, unless an option's label equals the query. */
18
+ createRow?: boolean;
19
+ /** Every query change passes through; the return value is the query kept. */
20
+ onQueryChange?(next: string): string;
21
+ /** Keys the owner wants first; return true when handled. */
22
+ onInputKey?(e: KeyboardEvent<HTMLInputElement>, query: string): boolean;
23
+ /** Escape, or focus leaving the control; `query` is what was typed and not picked. */
24
+ onClose?(query: string, reason: "escape" | "blur"): void;
25
+ autoFocus?: boolean;
26
+ /** Start open: the tags input, and static-markup tests. */
27
+ initialOpen?: boolean;
28
+ className?: string;
29
+ };
30
+ export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, }: ComboboxProps): import("react").JSX.Element;
@@ -0,0 +1,89 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // A text input with a listbox under it (spec 2026-09-17 §1): the reference
4
+ // picker and the tags input are both this. It knows nothing about entries
5
+ // or tags — options in, a pick out. Decisions live in ./combo-box.ts.
6
+ // Rows keep the input focused (mousedown is swallowed), so "focus left the
7
+ // control" is the one close signal besides Escape.
8
+ import { useId, useLayoutEffect, useRef, useState } from "react";
9
+ import { ChevronDown } from "lucide-react";
10
+ import { cx } from "./primitives.js";
11
+ import { filterOptions, opensUp, showsCreate, stepActive } from "./combo-box.js";
12
+ const keepFocus = (e) => e.preventDefault();
13
+ /** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
14
+ function clipBand(el) {
15
+ let top = 0;
16
+ let bottom = window.innerHeight;
17
+ for (let p = el.parentElement; p; p = p.parentElement) {
18
+ if (/(auto|scroll|hidden|clip)/.test(getComputedStyle(p).overflowY)) {
19
+ const r = p.getBoundingClientRect();
20
+ top = Math.max(top, r.top);
21
+ bottom = Math.min(bottom, r.bottom);
22
+ break;
23
+ }
24
+ }
25
+ return { top, bottom };
26
+ }
27
+ export function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen = false, className, }) {
28
+ const rest = onSubmit ? -1 : 0; // the active row a fresh query starts from
29
+ const [query, setQuery] = useState("");
30
+ const [open, setOpen] = useState(initialOpen);
31
+ const [active, setActive] = useState(rest);
32
+ const root = useRef(null);
33
+ const list = useRef(null);
34
+ const [up, setUp] = useState(false);
35
+ const id = useId();
36
+ const shown = filterOptions(options, query);
37
+ const create = onSubmit !== undefined && createRow === true && showsCreate(options, query);
38
+ const count = shown.length + (create ? 1 : 0);
39
+ // Free text with nothing to offer shows no popover; a picker says why it is empty.
40
+ const popover = open && !disabled && (count > 0 || !onSubmit);
41
+ // A field at the bottom of a scrolling form has no room under it: the list hangs above instead.
42
+ useLayoutEffect(() => {
43
+ if (!popover || !root.current || !list.current)
44
+ return;
45
+ const box = root.current.getBoundingClientRect();
46
+ const band = clipBand(root.current);
47
+ setUp(opensUp({ below: band.bottom - box.bottom - 4, above: box.top - band.top - 4, height: list.current.offsetHeight }));
48
+ }, [popover, count]);
49
+ const reset = () => { setQuery(""); setActive(rest); };
50
+ const close = (reason) => {
51
+ setOpen(false);
52
+ onClose?.(query, reason);
53
+ reset();
54
+ };
55
+ const pick = (i) => {
56
+ if (i < shown.length)
57
+ onPick(shown[i].value);
58
+ else
59
+ onSubmit?.(query);
60
+ reset();
61
+ if (!keepOpen)
62
+ setOpen(false);
63
+ };
64
+ const onKeyDown = (e) => {
65
+ if (onInputKey?.(e, query))
66
+ return;
67
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
68
+ e.preventDefault();
69
+ setOpen(true);
70
+ setActive((a) => stepActive(a, count, e.key === "ArrowDown" ? 1 : -1));
71
+ }
72
+ else if (e.key === "Enter") {
73
+ e.preventDefault();
74
+ if (open && active >= 0 && active < count)
75
+ pick(active);
76
+ else if (onSubmit && query.trim() !== "") {
77
+ onSubmit(query);
78
+ reset();
79
+ }
80
+ }
81
+ else if (e.key === "Escape") {
82
+ if (open)
83
+ e.stopPropagation(); // a modal behind it stays open
84
+ close("escape");
85
+ }
86
+ };
87
+ return (_jsxs("div", { ref: root, className: cx("sm-combo", className), children: [_jsx("input", { className: "sm-input", role: "combobox", "aria-label": label, "aria-autocomplete": "list", "aria-expanded": popover, "aria-controls": `${id}-list`, "aria-activedescendant": popover && active >= 0 ? `${id}-opt-${active}` : undefined, placeholder: placeholder, disabled: disabled, autoFocus: autoFocus, value: query, onFocus: () => setOpen(true), onClick: () => setOpen(true), onChange: (e) => { setQuery(onQueryChange ? onQueryChange(e.target.value) : e.target.value); setActive(rest); setOpen(true); }, onKeyDown: onKeyDown, onBlur: (e) => { if (open && !root.current?.contains(e.relatedTarget))
88
+ close("blur"); } }), _jsx(ChevronDown, { className: "sm-combo__chevron", size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), popover && (_jsxs("div", { ref: list, id: `${id}-list`, role: "listbox", "aria-label": label, className: cx("sm-picker sm-combo__list", up && "sm-combo__list--up"), children: [count === 0 && _jsx("div", { className: "sm-combo__empty", children: options.length === 0 ? emptyText ?? "No matches" : "No matches" }), shown.map((o, i) => (_jsx("button", { id: `${id}-opt-${i}`, type: "button", role: "option", "aria-selected": i === active, className: cx("sm-picker__item", i === active && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(i), children: renderOption ? renderOption(o) : o.label }, o.value))), create && (_jsxs("button", { id: `${id}-opt-${shown.length}`, type: "button", role: "option", "aria-selected": active === shown.length, className: cx("sm-picker__item", active === shown.length && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(shown.length), children: ["Create \u201C", query.trim(), "\u201D"] }))] }))] }));
89
+ }
@@ -0,0 +1,16 @@
1
+ export type ComboOption = {
2
+ value: string;
3
+ label: string;
4
+ };
5
+ /** A case-insensitive substring match on the label; an empty query keeps every option. */
6
+ export declare function filterOptions(options: ComboOption[], query: string): ComboOption[];
7
+ /** Whether `Create "query"` is offered: something is typed and no label equals it. */
8
+ export declare function showsCreate(options: ComboOption[], query: string): boolean;
9
+ /** The next active row, wrapping. -1 is "no row" (free text): ↓ enters at the top, ↑ at the bottom. */
10
+ export declare function stepActive(active: number, count: number, dir: 1 | -1): number;
11
+ /** Whether the list hangs above the input: only when it does not fit below and there is more room above. */
12
+ export declare const opensUp: (room: {
13
+ below: number;
14
+ above: number;
15
+ height: number;
16
+ }) => boolean;
@@ -0,0 +1,21 @@
1
+ const fold = (s) => s.trim().toLocaleLowerCase();
2
+ /** A case-insensitive substring match on the label; an empty query keeps every option. */
3
+ export function filterOptions(options, query) {
4
+ const q = fold(query);
5
+ return q === "" ? options : options.filter((o) => o.label.toLocaleLowerCase().includes(q));
6
+ }
7
+ /** Whether `Create "query"` is offered: something is typed and no label equals it. */
8
+ export function showsCreate(options, query) {
9
+ const q = fold(query);
10
+ return q !== "" && !options.some((o) => fold(o.label) === q);
11
+ }
12
+ /** The next active row, wrapping. -1 is "no row" (free text): ↓ enters at the top, ↑ at the bottom. */
13
+ export function stepActive(active, count, dir) {
14
+ if (count === 0)
15
+ return -1;
16
+ if (active < 0)
17
+ return dir === 1 ? 0 : count - 1;
18
+ return (active + dir + count) % count;
19
+ }
20
+ /** Whether the list hangs above the input: only when it does not fit below and there is more room above. */
21
+ export const opensUp = (room) => room.height > room.below && room.above > room.below;
@@ -858,6 +858,42 @@ body:has(.sm-admin){margin:0}
858
858
  width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
859
859
  color:var(--accent);display:grid;place-items:center;font-size:13px;
860
860
  }
861
+ /* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it. */
862
+ .sm-combo{position:relative}
863
+ .sm-combo .sm-input{width:100%;padding-right:30px}
864
+ .sm-combo__chevron{position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--text-muted)}
865
+ .sm-combo__list{position:absolute;top:calc(100% + 4px);left:0;right:0;width:auto;max-height:280px;overflow-y:auto;z-index:60}
866
+ .sm-combo__list--up{top:auto;bottom:calc(100% + 4px)}
867
+ .sm-combo__empty{padding:8px 10px;color:var(--text-muted);font-size:var(--text-sm)}
868
+ /* Reference picker (spec 2026-09-17 §2): the picked entries under the search input. */
869
+ .sm-refpicker{display:flex;flex-direction:column;gap:6px;min-width:0}
870
+ .sm-refpicker__rows{display:flex;flex-direction:column;gap:4px}
871
+ .sm-refpicker__title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
872
+ .sm-refrow{
873
+ display:flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 6px 0 10px;
874
+ border:1px solid var(--line);border-radius:var(--radius-md);background:var(--surface);min-width:0;
875
+ }
876
+ .sm-refrow:has(.sm-grip){padding-left:6px}
877
+ .sm-refrow:hover .sm-grip{opacity:1}
878
+ .sm-refrow--muted .sm-refpicker__title{color:var(--text-muted)}
879
+ .sm-refrow__note{font-size:var(--text-sm)}
880
+ .sm-refrow a.sm-iconbtn{text-decoration:none}
881
+ /* Tags (spec 2026-09-17 §5): chips that wrap, a dashed + Add, the inline input in its place. */
882
+ .sm-tags{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
883
+ .sm-tag{
884
+ display:inline-flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 10px;border:0;
885
+ border-radius:var(--radius-sm);background:var(--surface-inset);color:var(--text);font:inherit;font-size:var(--text-sm);white-space:nowrap;
886
+ transition:background .2s;
887
+ }
888
+ .sm-tag--flash{background:var(--accent-soft)}
889
+ .sm-tag__x{border:0;background:transparent;padding:0;color:var(--text-muted);cursor:pointer;font:inherit;line-height:1}
890
+ .sm-tag__x:hover{color:var(--text)}
891
+ .sm-tag--add{background:transparent;border:1px dashed var(--line);color:var(--text-muted);cursor:pointer}
892
+ .sm-tag--add:hover{color:var(--text);border-color:var(--text-muted)}
893
+ .sm-combo--tag{width:180px}
894
+ .sm-combo--tag .sm-input{height:var(--control-h-sm);padding-right:10px}
895
+ .sm-combo--tag .sm-combo__chevron{display:none}
896
+ .sm-combo--tag .sm-combo__list{right:auto;width:240px}
861
897
  .sm-dashed{
862
898
  border:1px dashed var(--line-dashed);border-radius:var(--radius-lg);
863
899
  display:flex;align-items:center;justify-content:center;gap:8px;color:var(--text-subtle);
@@ -8,6 +8,7 @@
8
8
  import { optionValues } from "../fields.js";
9
9
  import { isHidden } from "../conditions.js";
10
10
  import { cssProblem } from "../css.js";
11
+ import { normalizeTag, sameTag } from "../tags.js";
11
12
  import { hasContent } from "./richtext-doc.js";
12
13
  import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
13
14
  import { rowsProblems } from "../custom/rows.js";
@@ -89,6 +90,44 @@ function validateValue(path, value, d, errors, elements, scope) {
89
90
  }
90
91
  return;
91
92
  }
93
+ // Refs (spec 2026-09-17 §3): the shape and the count. Whether a target exists
94
+ // and is published is publish integrity's question (store.ts), not this one's.
95
+ if (d.type === "ref") {
96
+ if (typeof value !== "string")
97
+ errors.push({ field: path, message: "Must be an entry." });
98
+ return;
99
+ }
100
+ if (d.type === "refList") {
101
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || v === "")) {
102
+ errors.push({ field: path, message: "Must be a list of entries." });
103
+ return;
104
+ }
105
+ if (typeof d.min === "number" && value.length < d.min)
106
+ errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
107
+ if (typeof d.max === "number" && value.length > d.max)
108
+ errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
109
+ // The refs index keys on (source, field, target): a repeat renders twice and indexes once.
110
+ if (new Set(value).size !== value.length)
111
+ errors.push({ field: path, message: "The same entry is listed twice." });
112
+ return;
113
+ }
114
+ if (d.type === "tags") {
115
+ if (!Array.isArray(value)) {
116
+ errors.push({ field: path, message: "Must be a list of tags." });
117
+ return;
118
+ }
119
+ if (typeof d.min === "number" && value.length < d.min)
120
+ errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
121
+ if (typeof d.max === "number" && value.length > d.max)
122
+ errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
123
+ value.forEach((v, i) => {
124
+ if (typeof v !== "string" || v === "" || normalizeTag(v) !== v)
125
+ errors.push({ field: `${path}.${i}`, message: "Must be a trimmed, non-empty tag." });
126
+ else if (value.slice(0, i).some((u) => typeof u === "string" && sameTag(u, v)))
127
+ errors.push({ field: `${path}.${i}`, message: "Duplicate tag." });
128
+ });
129
+ return;
130
+ }
92
131
  if (d.type === "checkboxes") {
93
132
  if (!Array.isArray(value)) {
94
133
  errors.push({ field: path, message: "Must be a list." });
@@ -0,0 +1,17 @@
1
+ export declare const DEFAULT_ADMIN_PATH = "/admin";
2
+ /** `/` plus exactly one segment (spec 2026-09-17 slug guard §1): with one
3
+ * segment only a ROOT slug can collide with the admin, so the guard is a
4
+ * string comparison. */
5
+ export declare function assertAdminPath(path: unknown): asserts path is string;
6
+ /** True when `slug` is the admin's own segment: Next serves the static
7
+ * admin folder before the site catch-all, so a root page, a collection
8
+ * path or a fixed slug that takes it can never be opened. */
9
+ export declare const isAdminSlug: (slug: string, adminPath: string) => boolean;
10
+ /** What an editor reads when a root page would take the admin's segment. */
11
+ export declare const adminSlugTaken: (adminPath: string) => string;
12
+ /** `path` is "" or starts with "/": adminHref("/studio", "/pages") → "/studio/pages". */
13
+ export declare const adminHref: (base: string, path: string) => string;
14
+ /** The message for a shell mounted outside its base — the route folder
15
+ * and `admin.path` disagree (spec §4). Null inside the base, and before
16
+ * mount, when the pathname is still "". */
17
+ export declare function mountMismatch(pathname: string, base: string): string | null;
@@ -0,0 +1,32 @@
1
+ // packages/smoodly/src/admin-path.ts
2
+ // The admin's mount path (spec 2026-09-17): "/admin" unless the config
3
+ // moves it. Pure and framework-free — the config, the ops and the shell
4
+ // all read it from here.
5
+ import { isSegment } from "./paths.js";
6
+ export const DEFAULT_ADMIN_PATH = "/admin";
7
+ /** `/` plus exactly one segment (spec 2026-09-17 slug guard §1): with one
8
+ * segment only a ROOT slug can collide with the admin, so the guard is a
9
+ * string comparison. */
10
+ export function assertAdminPath(path) {
11
+ const ok = typeof path === "string" && path.startsWith("/") && isSegment(path.slice(1));
12
+ if (!ok) {
13
+ throw new Error(`smoodly: admin.path "${String(path)}" is not a valid path — use one /segment of lowercase letters, digits and single hyphens, like "/admin" or "/studio".`);
14
+ }
15
+ }
16
+ /** True when `slug` is the admin's own segment: Next serves the static
17
+ * admin folder before the site catch-all, so a root page, a collection
18
+ * path or a fixed slug that takes it can never be opened. */
19
+ export const isAdminSlug = (slug, adminPath) => slug === adminPath.slice(1);
20
+ /** What an editor reads when a root page would take the admin's segment. */
21
+ export const adminSlugTaken = (adminPath) => `smoodly: "${adminPath}" is where the admin lives (admin.path) — pick another slug.`;
22
+ /** `path` is "" or starts with "/": adminHref("/studio", "/pages") → "/studio/pages". */
23
+ export const adminHref = (base, path) => `${base}${path}`;
24
+ /** The message for a shell mounted outside its base — the route folder
25
+ * and `admin.path` disagree (spec §4). Null inside the base, and before
26
+ * mount, when the pathname is still "". */
27
+ export function mountMismatch(pathname, base) {
28
+ if (pathname === "" || pathname === base || pathname.startsWith(`${base}/`))
29
+ return null;
30
+ const suggestion = `/${pathname.split("/").filter((s) => s !== "")[0] ?? ""}`;
31
+ return `The admin is open at ${pathname}, but admin.path in the Smoodly config is ${base}. Set admin: { path: "${suggestion}" } in the config, or move the route folder back.`;
32
+ }
package/dist/config.d.ts CHANGED
@@ -57,8 +57,13 @@ export type SmoodlyConfig = {
57
57
  maxSize?: number;
58
58
  accept?: string[];
59
59
  };
60
+ /** Where the admin is mounted (spec 2026-09-17): must match the route
61
+ * folder under app/(smoodly)/. Default "/admin". */
62
+ admin?: {
63
+ path?: string;
64
+ };
60
65
  };
61
- export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom"> & {
66
+ export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom" | "admin"> & {
62
67
  homePageSlug: string;
63
68
  /** How deep editors may nest pages in the tree. Default 1 (flat). */
64
69
  pageDepth: number;
@@ -74,6 +79,10 @@ export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom"> & {
74
79
  maxSize: number;
75
80
  accept: string[];
76
81
  };
82
+ /** The admin's mount path, always present. */
83
+ admin: {
84
+ path: string;
85
+ };
77
86
  };
78
87
  /** `global`: the placement node type and an admin route; `asset`: the refs
79
88
  * target of every media edge; `media`: the admin's Media screen route. */
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
2
2
  import { DEFAULT_MEDIA } from "./media.js";
3
+ import { DEFAULT_ADMIN_PATH, assertAdminPath, isAdminSlug } from "./admin-path.js";
3
4
  import { resolveStyleValues, styleDescriptors } from "./styles.js";
4
5
  import { resolveCustom } from "./custom/index.js";
5
6
  /** `global`: the placement node type and an admin route; `asset`: the refs
@@ -55,8 +56,9 @@ function assertGlobalSections(config) {
55
56
  /** Every root-level segment a registration claims, per locale: fixed page
56
57
  * slugs and collection paths. Two claims on one (locale, segment) are a
57
58
  * conflict — except a fixed page sitting at a collection's segment in
58
- * EVERY locale of that path, which is the collection's index page. */
59
- function assertRootSegments(config) {
59
+ * EVERY locale of that path, which is the collection's index page. The
60
+ * admin's own segment is refused outright (spec 2026-09-17 slug guard §5). */
61
+ function assertRootSegments(config, adminPath) {
60
62
  const { default: def, supported } = config.locales;
61
63
  // Mounts claim exactly the locales their path names; fixed pages fill an
62
64
  // omitted locale from the default one, so the check must fill the same
@@ -72,6 +74,9 @@ function assertRootSegments(config) {
72
74
  }
73
75
  for (const [locale, segment] of Object.entries(pageSegments(slug))) {
74
76
  assertSegment(segment, `page "${p.schema.name}" fixed slug`);
77
+ if (isAdminSlug(segment, adminPath)) {
78
+ throw new Error(`smoodly: page "${p.schema.name}" has the fixed slug "${segment}", where the admin lives — change its slug or admin.path.`);
79
+ }
75
80
  const owner = owners.get(`${locale} ${segment}`);
76
81
  if (owner) {
77
82
  throw new Error(`smoodly: page registrations "${owner}" and "${p.schema.name}" both claim the fixed slug "${segment}" (${locale}).`);
@@ -93,6 +98,9 @@ function assertRootSegments(config) {
93
98
  const segments = perLocale(c.path);
94
99
  for (const [locale, segment] of Object.entries(segments)) {
95
100
  assertSegment(segment, `collection "${c.name}" path`);
101
+ if (isAdminSlug(segment, adminPath)) {
102
+ throw new Error(`smoodly: collection "${c.name}" is mounted at "${adminPath}", where the admin lives — change its path or admin.path.`);
103
+ }
96
104
  const other = collectionOwners.get(`${locale} ${segment}`);
97
105
  if (other) {
98
106
  throw new Error(`smoodly: collections "${other}" and "${c.name}" both claim the root segment "${segment}" (${locale}).`);
@@ -169,7 +177,9 @@ export function defineConfig(config) {
169
177
  assertUniqueNames(config);
170
178
  assertGlobalSections(config);
171
179
  assertToolsetElements(config);
172
- assertRootSegments(config);
180
+ const adminPath = config.admin?.path ?? DEFAULT_ADMIN_PATH;
181
+ assertAdminPath(adminPath);
182
+ assertRootSegments(config, adminPath);
173
183
  const depth = config.pages?.tree?.depth ?? 1;
174
184
  if (!Number.isInteger(depth) || depth < 1)
175
185
  throw new Error("smoodly: pages.tree.depth must be a positive integer.");
@@ -200,6 +210,7 @@ export function defineConfig(config) {
200
210
  homePageSlug,
201
211
  pageDepth: depth,
202
212
  media,
213
+ admin: { path: adminPath },
203
214
  styles,
204
215
  css: config.css === true,
205
216
  custom,
@@ -19,7 +19,7 @@ import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, uns
19
19
  /** The field types whose value is an array: a scalar `where` on one is
20
20
  * containment, and any-of on one is refused by the site layer (both
21
21
  * adapters read the text form of the whole array, never its elements). */
22
- export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list"]);
22
+ export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list", "tags"]);
23
23
  /** PostgREST's `->>`: a scalar's text, an array's or object's JSON text (Postgres
24
24
  * spaces an object's text differently, but no scalar equals either form). */
25
25
  const textForm = (value) => (value !== null && typeof value === "object" ? JSON.stringify(value) : String(value));
package/dist/fields.d.ts CHANGED
@@ -94,6 +94,9 @@ export declare const f: {
94
94
  radio: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>, {}>;
95
95
  /** Several values from a list, stored as an array, shown as a checkbox list. */
96
96
  checkboxes: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>[], {}>;
97
+ /** Short labels edited as chips, stored as a string array: trimmed, no
98
+ * case-insensitive duplicates (spec 2026-09-17 §4). `.min`/`.max` count tags. */
99
+ tags: () => FieldBuilder<string[], {}>;
97
100
  object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S & { [K in keyof S]: NotLocalized<S[K]>; }) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
98
101
  /** A repeating item: any builder, but no list at any depth inside the item
99
102
  * — a list in an object in a list is still a nested list on screen, and a
package/dist/fields.js CHANGED
@@ -107,6 +107,9 @@ export const f = {
107
107
  radio: (options) => builder({ type: "radio", options: normalizeOptions(options) }),
108
108
  /** Several values from a list, stored as an array, shown as a checkbox list. */
109
109
  checkboxes: (options) => builder({ type: "checkboxes", options: normalizeOptions(options) }),
110
+ /** Short labels edited as chips, stored as a string array: trimmed, no
111
+ * case-insensitive duplicates (spec 2026-09-17 §4). `.min`/`.max` count tags. */
112
+ tags: () => builder({ type: "tags" }),
110
113
  object: (shape) => {
111
114
  for (const [k, v] of Object.entries(shape))
112
115
  assertContainerItem("f.object", v.descriptor, k);
@@ -124,6 +127,8 @@ export const f = {
124
127
  const nested = findMatch(d, (sub) => sub.type === "list");
125
128
  if (nested !== null)
126
129
  throw new Error(`smoodly: f.list cannot hold another list (at ${describePath(nested)}) — nested lists are not supported.`);
130
+ if (d.type === "tags")
131
+ throw new Error("smoodly: f.list cannot hold f.tags() — a list of tag lists is a nested list; put the tags in an object item.");
127
132
  assertContainerItem("f.list", d);
128
133
  return builder({ type: "list", item: d });
129
134
  },
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export * from "./custom/index.ts";
21
21
  export type { Condition, ConditionOp, ConditionScope } from "./conditions.ts";
22
22
  export { defaultsOf, withDefaults } from "./field-defaults.ts";
23
23
  export { filterPageTree } from "./page-tree.ts";
24
+ export { DEFAULT_ADMIN_PATH } from "./admin-path.ts";
24
25
  export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.ts";
25
26
  export type { Crop, Hotspot, AssetKind, ImageValue, VideoValue, FileValue, AssetMeta, AssetRecord, ResolvedImage, ResolvedVideo, ResolvedFile, } from "./media.ts";
26
27
  export { z } from "./zones.ts";
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export { DEFAULT_STYLE_VALUES, STYLE_KEYS, SPACING_NONE, resolveStyleValues, sty
16
16
  export * from "./custom/index.js";
17
17
  export { defaultsOf, withDefaults } from "./field-defaults.js";
18
18
  export { filterPageTree } from "./page-tree.js";
19
+ export { DEFAULT_ADMIN_PATH } from "./admin-path.js";
19
20
  export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.js";
20
21
  export { z } from "./zones.js";
21
22
  export { defineConfig } from "./config.js";
package/dist/page.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describePath, findMatch } from "./fields.js";
2
2
  /** What a page field may be: a settings form, never a ref, a list or a document (spec 2026-09-10 page fields §1). */
3
- const PAGE_FIELD_TYPES = new Set(["text", "link", "date", "number", "boolean", "select", "radio", "checkboxes", "image", "video", "file", "object"]);
3
+ const PAGE_FIELD_TYPES = new Set(["text", "link", "date", "number", "boolean", "select", "radio", "checkboxes", "tags", "image", "video", "file", "object"]);
4
4
  /** The descriptor at a dotted path below `d` (object subfields only — page fields hold no lists). */
5
5
  function descriptorAt(d, path) {
6
6
  let cur = d;
@@ -365,7 +365,7 @@ export class SupabaseEntryStore {
365
365
  }
366
366
  for (const [key, want] of Object.entries(options?.where ?? {})) {
367
367
  // Any-of compares the text form (`->>`). A scalar is JSONB containment;
368
- // an array-valued field (checkboxes, refList, list) contains `[value]`,
368
+ // an array-valued field (checkboxes, refList, list, tags) contains `[value]`,
369
369
  // a scalar field `value` — the memory adapter's matchesWhere agrees.
370
370
  if (Array.isArray(want)) {
371
371
  query = query.in(`fields->>${key}`, want.map(String));
package/dist/tags.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** Trimmed, inner whitespace runs collapsed to one space. */
2
+ export declare const normalizeTag: (s: string) => string;
3
+ /** Tags are one tag when they differ only in case. */
4
+ export declare const sameTag: (a: string, b: string) => boolean;
5
+ /** Commit typed or pasted text: split on commas, normalize, drop empties and
6
+ * duplicates of what is already there, stop at `max`. A part that matches a
7
+ * `known` tag takes its casing. `duplicate` is the existing tag the first
8
+ * dropped part hit, so the widget can flash that chip. */
9
+ export declare function addTags(current: readonly string[], input: string, opts?: {
10
+ max?: number;
11
+ known?: readonly string[];
12
+ }): {
13
+ tags: string[];
14
+ duplicate: string | null;
15
+ };
16
+ /** Every tag in a set of field values (each a tag array, or anything else), sorted. Where
17
+ * entries disagree on a tag's casing the most used one wins, a tie going to plain string
18
+ * order — never to the order the values arrived in, which is the store's business. */
19
+ export declare function distinctTags(values: readonly unknown[]): string[];
package/dist/tags.js ADDED
@@ -0,0 +1,50 @@
1
+ // The rules of an f.tags() value (spec 2026-09-17 §4) as pure functions:
2
+ // the chip widget, the validator and the suggestions op all go through
3
+ // these, so a tag means the same thing on every side.
4
+ /** Trimmed, inner whitespace runs collapsed to one space. */
5
+ export const normalizeTag = (s) => s.trim().replace(/\s+/g, " ");
6
+ const fold = (s) => s.toLocaleLowerCase();
7
+ /** Tags are one tag when they differ only in case. */
8
+ export const sameTag = (a, b) => fold(a) === fold(b);
9
+ /** Commit typed or pasted text: split on commas, normalize, drop empties and
10
+ * duplicates of what is already there, stop at `max`. A part that matches a
11
+ * `known` tag takes its casing. `duplicate` is the existing tag the first
12
+ * dropped part hit, so the widget can flash that chip. */
13
+ export function addTags(current, input, opts = {}) {
14
+ const tags = [...current];
15
+ let duplicate = null;
16
+ for (const part of input.split(",")) {
17
+ const typed = normalizeTag(part);
18
+ if (typed === "")
19
+ continue;
20
+ const tag = opts.known?.find((k) => sameTag(k, typed)) ?? typed;
21
+ const hit = tags.find((t) => sameTag(t, tag));
22
+ if (hit !== undefined) {
23
+ duplicate ?? (duplicate = hit);
24
+ continue;
25
+ }
26
+ if (typeof opts.max === "number" && tags.length >= opts.max)
27
+ break;
28
+ tags.push(tag);
29
+ }
30
+ return { tags, duplicate };
31
+ }
32
+ /** Every tag in a set of field values (each a tag array, or anything else), sorted. Where
33
+ * entries disagree on a tag's casing the most used one wins, a tie going to plain string
34
+ * order — never to the order the values arrived in, which is the store's business. */
35
+ export function distinctTags(values) {
36
+ const variants = new Map();
37
+ for (const value of values) {
38
+ if (!Array.isArray(value))
39
+ continue;
40
+ for (const tag of value) {
41
+ if (typeof tag !== "string" || tag === "")
42
+ continue;
43
+ const counts = variants.get(fold(tag)) ?? new Map();
44
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
45
+ variants.set(fold(tag), counts);
46
+ }
47
+ }
48
+ const pick = (counts) => [...counts].sort(([a, n], [b, m]) => m - n || (a < b ? -1 : 1))[0][0];
49
+ return [...variants.values()].map(pick).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {