smoodly 0.0.8 → 0.0.9

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.
package/README.md CHANGED
@@ -314,8 +314,28 @@ Implemented:
314
314
  with "used on N pages" from `refs`. Wired through the `shared` option
315
315
  on the entry store and the site layer.
316
316
 
317
- Not yet: richtext components (TipTap; `plainToRichtext`/
318
- `richtextToPlain` exist but no rich editor widget), drag-and-drop
317
+ - Lists and editable objects (spec 2026-09-07 list field): `f.list(item)`
318
+ holds any builder but another list and refuses a ref or `.localized()`
319
+ anywhere inside (refs are top-level only; localization is whole-value on
320
+ a list or object). `.min`/`.max` count items. The admin edits an object
321
+ as an indented fieldset and a list as rows — summary, collapse, move up
322
+ and down, remove, "N of max", Add disabled at max — recursively through
323
+ `FieldWidget`, with the row logic as pure functions in `admin/forms/list.ts`.
324
+ `validateFields` recurses with dotted paths (`points.2.heading`) and
325
+ checks list length; the serializer ships a list's `item`.
326
+
327
+ - Richtext renders and round-trips: `RichText` (package root, pure React,
328
+ no wrapper) turns the TipTap document into paragraphs, headings, lists,
329
+ blockquotes, hard breaks and bold/italic/link marks, unknown nodes
330
+ degrading to their children. The interim textarea widget speaks a line
331
+ syntax (`#` headings, `- ` and `1. ` lists, `> ` quotes, blank line
332
+ between paragraphs, hard break between adjacent lines) over the same
333
+ JSON, so seeded structure survives an edit.
334
+ A link mark reaches the page only with a safe scheme (http(s), mailto,
335
+ tel, site-relative).
336
+
337
+ Not yet: the TipTap richtext widget (the textarea's line syntax is the
338
+ interim editor; `RichText` renders), Elements in richtext, drag-and-drop
319
339
  reordering (move-up/move-down buttons in the editor, Move up / Move down
320
340
  in the Pages list's row menu), undo/redo, sample/placeholder content
321
341
  marking, per-locale section visibility conditions (OQ #3 — each locale
@@ -336,6 +356,115 @@ Both packages move together. From the repo root:
336
356
  `examples/site` in that build, so the template always matches the example
337
357
  site at the tagged commit.
338
358
 
359
+ ## Follow-ups (logged 2026-09-07, starter)
360
+
361
+ - A paired component (`smoodly.section`) accepts only its schema's props, so
362
+ a layout cannot hand the footer the contact item or the header the locale
363
+ links through the paired `Footer`/`Header`; the starter's layouts render
364
+ `FooterView`/`HeaderView` directly with the extra prop. A supported way
365
+ to pass layout-only props through a paired component (or a `children`
366
+ slot) would let the layouts use the registered component.
367
+ - Page views and entry pages receive no `locale`; the starter's views get
368
+ it from the app's strings seam (`next-intl`'s `getLocale`, or a constant
369
+ in the single-language scaffold). Adding `locale` to `PageContext` and
370
+ `EntryPageProps` would remove that dependency and let sections that
371
+ format dates stay synchronous.
372
+ - The content API is published-only, so the Articles index in the editor
373
+ canvas lists published articles while the hero above it is the draft.
374
+ A draft-aware `getEntries` for draft mode is the fix if that confuses.
375
+ - The image widget edits the URL only; `alt` is set by the seed and cannot
376
+ be changed in the admin.
377
+ - A per-locale collection path or fixed slug must name the default locale;
378
+ the installer rewrites the starter's two lines to name exactly the
379
+ chosen locales (`articlesSegments` in `scaffold.ts`), since a path
380
+ claiming a locale the site lacks fails the index-page rule at config
381
+ load. A "fall back to the first declared segment" rule in
382
+ `defineConfig` would make the patch unnecessary.
383
+ - The picker offers a shared item only in zones whose allow list includes
384
+ its section (`allowedShared` in `admin/tree-ops.ts`), so a shared item's
385
+ section is always also offered as an ordinary section. A "placeable
386
+ only" mode (a shared item whose section no zone lists) would let the
387
+ starter keep the quote shared-only.
388
+ - Registry names are one namespace across sections, elements, collections
389
+ and page shapes (`assertUniqueNames`); the starter names its People
390
+ section `peopleSection` and its Articles page `articlesIndex` to stay
391
+ clear of the collections. Namespacing by kind would let a section and a
392
+ collection share a name.
393
+ - The seed's early exit probes `/` alone, and `createPage` writes the path
394
+ row before the tree is published, so a crash between the home record and
395
+ the last page leaves a database every later run reports as "already
396
+ seeded"; recovery is `supabase db reset`. A probe on the last thing the
397
+ seed writes, or a per-step check, would make it resumable.
398
+ - With `fi` as the default locale the seed writes the English content into
399
+ `fi`. Before this fix the index page was seeded at `posts`
400
+ while the collection's `fi` path is `artikkelit`, so `ensureFixedNodes`
401
+ materialized a second, empty Articles page at `artikkelit` and the
402
+ links pointed at `/posts`; the seed now derives the segment from the
403
+ collection's `path` per locale. Left: the starter's copy in a `fi`
404
+ default is still English.
405
+ - Config-level section style defaults (`styles.sections` in `defineConfig`)
406
+ are not filled at render: a node saved without an explicit style emits
407
+ no `data-spacing`/`data-tone`, so a stylesheet cannot key the default
408
+ on the attribute. The starter pads body sections by element instead.
409
+ Filling the resolved defaults in the render pass (as the admin form
410
+ does) would make the attributes reliable (walk, 2026-09-07).
411
+ - A new list item's `select` subfield shows the first option but stores
412
+ nothing (`emptyValue` leaves it undefined), so publish fails with
413
+ `points.3.icon: Required` until the editor re-picks the value that is
414
+ already on screen. Seed the first option, or render a blank option so
415
+ the screen matches the value (walk, 2026-09-07).
416
+ - Publish validates the last saved draft, so a click inside the autosave
417
+ debounce reports fields the editor has just filled as `Required`. Flush
418
+ the pending save before validating, or disable Publish while a save is
419
+ pending (walk, 2026-09-07).
420
+ - The entry form cannot republish a published entry's newer draft: Save
421
+ writes a new draft version, the status control's `setPublished` returns
422
+ early when the status is unchanged, and nothing shows that the draft
423
+ differs from the published version. The workaround unpublishes and
424
+ republishes, which 404s the entry in between. Needs a "Publish changes"
425
+ action and a draft-edits indicator like the page editor's (walk,
426
+ 2026-09-07).
427
+ - Versions accumulate (`entry_versions` grew to four rows after one edit)
428
+ but no admin screen lists or restores them (walk, 2026-09-07).
429
+ - A list row's collapsed summary shows the item's first string subfield —
430
+ the header nav rows read `/`, `/posts` — where the label would identify
431
+ the row; `itemSummary` could prefer a `label`/`title`/`heading` key
432
+ (walk, 2026-09-07).
433
+ - New page / New folder ask for the slug through `window.prompt`, which
434
+ browser automation blocks and which cannot validate as you type; an
435
+ inline field would do both (walk, 2026-09-07).
436
+
437
+ ## Follow-ups (logged 2026-09-07, list field spec)
438
+
439
+ - Saving the interim richtext textarea drops marks (bold, italic, link);
440
+ flattens a hard break inside a heading, list item or blockquote line to a
441
+ space (a prefixed line cannot carry one, DESIGN.md §3 Richtext); and drops
442
+ anything the line syntax cannot express at all — nested lists,
443
+ multi-paragraph list items (joined with a space instead), and unknown
444
+ nodes such as a horizontal rule. All of it goes away with the TipTap
445
+ widget.
446
+ - Refs inside lists and objects are refused at build time (`f.list`,
447
+ `f.object`). A list of objects each holding a ref (a featured entry with
448
+ its own caption) needs the resolver, the refs index and the admin's
449
+ ref-option loading to address nested paths; add when a site asks.
450
+ - Heading levels 4–6 render but the line syntax writes `####`…`######`
451
+ for them; the spec names 1–3 as the supported set.
452
+ - `emptyValue` for an `image`/`video`/`file` item is `undefined`, so a new
453
+ media row starts empty; a required image item is then a validation
454
+ error until filled, which is the intended nudge but reads as an error
455
+ on a row the editor just added.
456
+ - `RichText` drops the anchor of a link mark whose href is not http(s),
457
+ mailto, tel or site-relative (`/`, `#`, `?`, `.`). The href is normalised
458
+ the way URL parsers do (tab, LF and CR removed) before the scheme test;
459
+ `javascript:`, `data:`, protocol-relative `//host` and the backslash form
460
+ `/\host` are rejected. The admin's link field does not validate on input yet.
461
+ - The list widget has no test for an object item holding a nested list
462
+ subfield.
463
+ - A list item's subtree still uses the row index as its React key, so a list
464
+ of objects each holding a list hands the inner rows another item's collapse
465
+ state when an outer row moves; fix with a stable per-item key when nested
466
+ lists get real use.
467
+
339
468
  ## Follow-ups (logged 2026-09-07, shared content spec)
340
469
 
341
470
  - `f.shared(() => item)` — a ref field on a section that renders a shared
@@ -361,15 +490,12 @@ site at the tagged commit.
361
490
  - `getSmoodlyShared` reads the store per request in draft mode and once
362
491
  per (item, locale) otherwise; a layout calling it for several items
363
492
  makes one round trip each — batch if it shows.
364
- - The Supabase entry store's singleton check is SELECT-then-INSERT with
365
- no unique index; two admin loads racing on an item's first
366
- materialization can create two rows. `ensureSharedItems` reads the
367
- first row either way, and the duplicate cannot be removed through the
368
- API `delete` refuses every shared row. Two ways to make the race
369
- impossible: a deterministic row id (uuid v5 of the item name, so the
370
- second insert fails on the primary key — no schema change), or a
371
- per-item partial unique index emitted by the SQL generator the way
372
- `collectionSQL` emits the rest.
493
+ - A shared item's row id is now uuid v5 of (space, name), so the primary
494
+ key ends the materialization race but only for rows created from now
495
+ on: an item materialized before this change keeps its random
496
+ `gen_random_uuid()` id, and a second load would still be able to insert
497
+ a duplicate beside it. Re-materializing such an item (delete the row in
498
+ SQL, reload the admin) moves it onto the derived id.
373
499
  - `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
374
500
  (`localeFromUrl`, `writeLocaleToUrl`, `applyResult`, `InfoRow`, the
375
501
  ref-options effect) and `SharedList.tsx` is the third copy of the
@@ -408,6 +534,14 @@ site at the tagged commit.
408
534
  - `ops.entries.move` expires entry tags directly and does not `fanOut`,
409
535
  so a reorder that changes what a shared item's `refList` renders does
410
536
  not expire `shared:<name>`.
537
+ - The package root (`src/index.ts`) must stay free of Node builtins:
538
+ every scaffolded section does `import { f, smoodly } from "smoodly"`,
539
+ and a `"use client"` section's browser build would fail on one.
540
+ `sharedEntryId` (`src/shared-id.ts`) moved off `node:crypto` onto Web
541
+ Crypto for this reason (fix pass, 2026-09-07). Nothing tests the
542
+ invariant itself — a future addition that reaches for `node:fs`,
543
+ `node:crypto`, etc. from a module reachable through the root would
544
+ regress it silently.
411
545
 
412
546
  ## Follow-ups (logged 2026-09-06)
413
547
 
@@ -1,10 +1,14 @@
1
- import type { ReactElement } from "react";
1
+ import { type ReactElement } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
- export declare function FieldWidget({ descriptor: d, value, onChange, error, refOptions, fieldKey, prefix, }: {
3
+ export declare function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, }: {
4
4
  descriptor: AdminField;
5
5
  value: unknown;
6
6
  onChange(value: unknown): void;
7
- error?: string;
7
+ errors?: Record<string, string>;
8
+ /** Dotted path of this widget in the form; defaults to `fieldKey`. */
9
+ path?: string;
10
+ /** Render the control alone, no label wrapper — list items. */
11
+ bare?: boolean;
8
12
  refOptions?: {
9
13
  id: string;
10
14
  title: string;
@@ -1,11 +1,26 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // One schema descriptor → one labelled control, in the design system's
4
+ // field language: uppercase 9px label, inset control with no border at
5
+ // rest, 1.5px accent border + soft ring on focus (all in ui/theme.tsx).
6
+ // Objects and lists recurse through this same component; the list's
7
+ // logic lives in ./list.ts as pure functions.
8
+ import { useState } from "react";
3
9
  import { plainToRichtext, richtextToPlain } from "../richtext.js";
4
- import { Field, IconButton } from "../ui/primitives.js";
10
+ import { Button, Field, IconButton } from "../ui/primitives.js";
11
+ import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
5
12
  const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
6
- export function FieldWidget({ descriptor: d, value, onChange, error, refOptions = [], fieldKey, prefix, }) {
13
+ export function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, }) {
14
+ const path = pathProp ?? fieldKey;
15
+ const message = errors?.[path];
16
+ // Unused for non-list types, but hooks must be unconditional — a list's
17
+ // rows key their collapse state to this rather than to array position,
18
+ // so a move or remove doesn't reassign which row looks open.
19
+ const [collapsed, setCollapsed] = useState(() => new Set());
7
20
  const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
8
21
  let control;
22
+ let counter;
23
+ let asDiv = d.type === "refList";
9
24
  switch (d.type) {
10
25
  case "text":
11
26
  case "link":
@@ -26,7 +41,7 @@ export function FieldWidget({ descriptor: d, value, onChange, error, refOptions
26
41
  break;
27
42
  }
28
43
  case "richtext":
29
- control = (_jsx("textarea", { className: "sm-textarea", rows: 5, value: richtextToPlain(value), onChange: (e) => onChange(plainToRichtext(e.target.value)) }));
44
+ control = _jsx(RichtextArea, { value: value, onChange: onChange });
30
45
  break;
31
46
  case "image":
32
47
  case "video":
@@ -44,11 +59,74 @@ export function FieldWidget({ descriptor: d, value, onChange, error, refOptions
44
59
  control = (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [ids.map((id, i) => (_jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { className: "sm-input", style: { display: "flex", alignItems: "center", flex: 1 }, children: byId.get(id) ? optionTitle(byId.get(id)) : id }), _jsx(IconButton, { label: "Remove", size: 28, onClick: () => onChange(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${id}-${i}`))), _jsxs("select", { className: "sm-select", value: "", onChange: (e) => e.target.value && onChange([...ids, e.target.value]), children: [_jsx("option", { value: "", children: "Add\u2026" }), refOptions.filter((o) => !ids.includes(o.id)).map((o) => (_jsx("option", { value: o.id, children: optionTitle(o) }, o.id)))] })] }));
45
60
  break;
46
61
  }
62
+ case "object": {
63
+ asDiv = true;
64
+ const obj = (value && typeof value === "object" && !Array.isArray(value) ? value : {});
65
+ control = (_jsx("div", { className: "sm-group", children: Object.entries(d.fields ?? {}).map(([k, sub]) => (_jsx(FieldWidget, { fieldKey: k, descriptor: sub, value: obj[k], errors: errors, path: `${path}.${k}`, onChange: (v) => onChange(patchObject(obj, k, v, d.optional === true)) }, k))) }));
66
+ break;
67
+ }
68
+ case "list": {
69
+ asDiv = true;
70
+ const items = Array.isArray(value) ? value : [];
71
+ const item = d.item ?? { type: "text" };
72
+ const atMax = typeof d.max === "number" && items.length >= d.max;
73
+ if (typeof d.max === "number")
74
+ counter = `${items.length} of ${d.max}`;
75
+ control = (_jsxs("div", { className: "sm-list", children: [items.map((it, i) => (_jsx(ListRow, { index: i, count: items.length, summary: itemSummary(it, i), open: !collapsed.has(i), onToggle: () => setCollapsed((prev) => {
76
+ const next = new Set(prev);
77
+ if (next.has(i))
78
+ next.delete(i);
79
+ else
80
+ next.add(i);
81
+ return next;
82
+ }), onMove: (to) => {
83
+ const moved = moveItem(items, i, to);
84
+ if (moved !== items) {
85
+ onChange(moved);
86
+ setCollapsed((prev) => moveIndex(prev, i, to));
87
+ }
88
+ }, onRemove: () => {
89
+ onChange(removeItem(items, i));
90
+ setCollapsed((prev) => removeIndex(prev, i));
91
+ }, children: _jsx(FieldWidget, { fieldKey: String(i), descriptor: item, value: it, errors: errors, path: `${path}.${i}`, bare: true, onChange: (v) => onChange(items.map((x, j) => (j === i ? v : x))) }) }, i))), _jsx(Button, { variant: "secondary", disabled: atMax, onClick: () => onChange(addItem(items, d)), children: "Add" })] }));
92
+ break;
93
+ }
47
94
  default:
48
95
  control = _jsx("textarea", { className: "sm-textarea", disabled: true, value: JSON.stringify(value ?? null, null, 2), rows: 2 });
49
96
  }
97
+ if (bare) {
98
+ return (_jsxs(_Fragment, { children: [control, message && _jsx("span", { className: "sm-error", children: message })] }));
99
+ }
50
100
  // A <label> activates its first labelable descendant, so it may only wrap a
51
- // single control — around refList's rows it would turn every click into a
52
- // press of the first row's remove button.
53
- return (_jsx(Field, { label: label(fieldKey), error: error, asDiv: d.type === "refList", children: control }));
101
+ // single control — around refList's rows, an object's fieldset or a list's
102
+ // rows it would turn every click into a press of the first button.
103
+ return (_jsx(Field, { label: label(fieldKey), counter: counter, error: message, asDiv: asDiv, children: control }));
104
+ }
105
+ /** One list item: a header with the summary and the row controls, the
106
+ * item's widget underneath while expanded. Header is a div, not a label.
107
+ * Collapse state lives in the parent `FieldWidget`, keyed to the item's
108
+ * index rather than to `key={i}` position, so a move or remove doesn't
109
+ * hand this row someone else's open/closed state. */
110
+ function ListRow({ index, count, summary, open, onToggle, onMove, onRemove, children }) {
111
+ return (_jsxs("div", { className: "sm-listrow", children: [_jsxs("div", { className: "sm-listrow__head", children: [_jsxs("button", { type: "button", className: "sm-listrow__toggle", "aria-expanded": open, onClick: onToggle, children: [_jsx("span", { "aria-hidden": "true", children: open ? "▾" : "▸" }), _jsx("span", { className: "sm-listrow__summary", children: summary })] }), _jsx(IconButton, { label: "Move up", disabled: index === 0, onClick: () => onMove(index - 1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", disabled: index === count - 1, onClick: () => onMove(index + 1), children: "\u2193" }), _jsx(IconButton, { label: "Remove", onClick: onRemove, children: "\u00D7" })] }), open && _jsx("div", { className: "sm-listrow__body", children: children })] }));
112
+ }
113
+ /** The interim richtext widget owns its text: the line syntax drops a trailing
114
+ * newline on the round trip, so deriving the value from the document each
115
+ * render would snap the caret back after Enter at the end of the text. Re-seeds
116
+ * when the document changes underneath it (load, locale switch). The parent must
117
+ * store the emitted document by reference — a clone or a server copy handed back
118
+ * would re-seed the text and bring the caret snap back. */
119
+ function RichtextArea({ value, onChange }) {
120
+ const [text, setText] = useState(() => richtextToPlain(value));
121
+ const [seen, setSeen] = useState(value);
122
+ if (value !== seen) {
123
+ setSeen(value);
124
+ setText(richtextToPlain(value));
125
+ }
126
+ return (_jsx("textarea", { className: "sm-textarea", rows: 5, value: text, onChange: (e) => {
127
+ const doc = plainToRichtext(e.target.value);
128
+ setText(e.target.value);
129
+ setSeen(doc);
130
+ onChange(doc);
131
+ } }));
54
132
  }
@@ -0,0 +1,18 @@
1
+ import type { AdminField } from "../serialize.ts";
2
+ /** An empty item: an object of its subfields' defaults, else the default. */
3
+ export declare function emptyValue(d: AdminField): unknown;
4
+ export declare function addItem(items: unknown[], list: AdminField): unknown[];
5
+ export declare function removeItem(items: unknown[], index: number): unknown[];
6
+ export declare function moveItem(items: unknown[], from: number, to: number): unknown[];
7
+ /** Permutes a set of item indices (e.g. collapse state) for the same move
8
+ * `moveItem` just applied: the index at `from` becomes `to`; indices strictly
9
+ * between the two shift by one toward `from`; every other index is unchanged. */
10
+ export declare function moveIndex(set: Set<number>, from: number, to: number): Set<number>;
11
+ /** Permutes a set of item indices for a removal at `index`: drops it, and
12
+ * shifts every higher index down by one. */
13
+ export declare function removeIndex(set: Set<number>, index: number): Set<number>;
14
+ /** The row header's text: the value, or its first non-empty string, or "Item N". */
15
+ export declare function itemSummary(value: unknown, index: number): string;
16
+ /** A subfield change on an object value. `collapseEmpty` (an optional
17
+ * object) turns an object with no defined values into `undefined`. */
18
+ export declare function patchObject(obj: Record<string, unknown>, key: string, value: unknown, collapseEmpty: boolean): Record<string, unknown> | undefined;
@@ -0,0 +1,81 @@
1
+ /** An empty item: an object of its subfields' defaults, else the default. */
2
+ export function emptyValue(d) {
3
+ if (d.type === "object") {
4
+ const out = {};
5
+ for (const [k, sub] of Object.entries(d.fields ?? {})) {
6
+ const v = emptyValue(sub);
7
+ if (v !== undefined)
8
+ out[k] = v;
9
+ }
10
+ return out;
11
+ }
12
+ return d.default;
13
+ }
14
+ export function addItem(items, list) {
15
+ if (typeof list.max === "number" && items.length >= list.max)
16
+ return items;
17
+ return [...items, list.item ? emptyValue(list.item) : undefined];
18
+ }
19
+ export function removeItem(items, index) {
20
+ return items.filter((_, i) => i !== index);
21
+ }
22
+ export function moveItem(items, from, to) {
23
+ if (to < 0 || to >= items.length || from === to)
24
+ return items;
25
+ const next = [...items];
26
+ const [moved] = next.splice(from, 1);
27
+ next.splice(to, 0, moved);
28
+ return next;
29
+ }
30
+ /** Permutes a set of item indices (e.g. collapse state) for the same move
31
+ * `moveItem` just applied: the index at `from` becomes `to`; indices strictly
32
+ * between the two shift by one toward `from`; every other index is unchanged. */
33
+ export function moveIndex(set, from, to) {
34
+ const next = new Set();
35
+ for (const i of set) {
36
+ if (i === from)
37
+ next.add(to);
38
+ else if (from < to && i > from && i <= to)
39
+ next.add(i - 1);
40
+ else if (to < from && i >= to && i < from)
41
+ next.add(i + 1);
42
+ else
43
+ next.add(i);
44
+ }
45
+ return next;
46
+ }
47
+ /** Permutes a set of item indices for a removal at `index`: drops it, and
48
+ * shifts every higher index down by one. */
49
+ export function removeIndex(set, index) {
50
+ const next = new Set();
51
+ for (const i of set) {
52
+ if (i === index)
53
+ continue;
54
+ next.add(i > index ? i - 1 : i);
55
+ }
56
+ return next;
57
+ }
58
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
59
+ /** The row header's text: the value, or its first non-empty string, or "Item N". */
60
+ export function itemSummary(value, index) {
61
+ if (nonEmpty(value))
62
+ return value;
63
+ if (value && typeof value === "object" && !Array.isArray(value)) {
64
+ const hit = Object.values(value).find(nonEmpty);
65
+ if (hit)
66
+ return hit;
67
+ }
68
+ return `Item ${index + 1}`;
69
+ }
70
+ /** A subfield change on an object value. `collapseEmpty` (an optional
71
+ * object) turns an object with no defined values into `undefined`. */
72
+ export function patchObject(obj, key, value, collapseEmpty) {
73
+ const next = { ...obj };
74
+ if (value === undefined)
75
+ delete next[key];
76
+ else
77
+ next[key] = value;
78
+ if (collapseEmpty && Object.values(next).every((v) => v === undefined))
79
+ return undefined;
80
+ return next;
81
+ }
@@ -1,14 +1,16 @@
1
- type TextNode = {
2
- type: "text";
3
- text: string;
1
+ export type RichtextNode = {
2
+ type: string;
3
+ attrs?: Record<string, unknown>;
4
+ content?: RichtextNode[];
5
+ text?: string;
6
+ marks?: {
7
+ type: string;
8
+ attrs?: Record<string, unknown>;
9
+ }[];
4
10
  };
5
- type Paragraph = {
6
- type: "paragraph";
7
- content?: TextNode[];
8
- };
9
- export declare function richtextToPlain(doc: unknown): string;
10
- export declare function plainToRichtext(text: string): {
11
+ export type RichtextDoc = {
11
12
  type: "doc";
12
- content: Paragraph[];
13
+ content: RichtextNode[];
13
14
  };
14
- export {};
15
+ export declare function plainToRichtext(text: string): RichtextDoc;
16
+ export declare function richtextToPlain(doc: unknown): string;
@@ -1,17 +1,98 @@
1
- export function richtextToPlain(doc) {
2
- const d = doc;
3
- if (!d || d.type !== "doc" || !Array.isArray(d.content))
4
- return "";
5
- return d.content
6
- .map((p) => (p.content ?? []).map((t) => t.text ?? "").join(""))
7
- .join("\n");
1
+ const textOf = (s) => (s ? [{ type: "text", text: s }] : []);
2
+ function paragraph(lines) {
3
+ const content = [];
4
+ lines.forEach((line, i) => {
5
+ if (i > 0)
6
+ content.push({ type: "hardBreak" });
7
+ content.push(...textOf(line));
8
+ });
9
+ return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" };
8
10
  }
11
+ const listItem = (s) => ({ type: "listItem", content: [paragraph([s])] });
12
+ const HEADING = /^(#{1,6}) (.*)$/;
13
+ const BULLET = /^- /;
14
+ const ORDERED = /^\d+\. /;
15
+ const QUOTE = /^> ?/;
9
16
  export function plainToRichtext(text) {
10
- return {
11
- type: "doc",
12
- content: text.split("\n").map((line) => ({
13
- type: "paragraph",
14
- ...(line ? { content: [{ type: "text", text: line }] } : {}),
15
- })),
17
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
18
+ const content = [];
19
+ let para = [];
20
+ const flush = () => {
21
+ if (para.length > 0)
22
+ content.push(paragraph(para));
23
+ para = [];
16
24
  };
25
+ let i = 0;
26
+ while (i < lines.length) {
27
+ const line = lines[i];
28
+ if (line.trim() === "") {
29
+ flush();
30
+ i++;
31
+ continue;
32
+ }
33
+ const heading = HEADING.exec(line);
34
+ if (heading) {
35
+ flush();
36
+ content.push({ type: "heading", attrs: { level: heading[1].length }, content: textOf(heading[2]) });
37
+ i++;
38
+ continue;
39
+ }
40
+ if (BULLET.test(line) || ORDERED.test(line)) {
41
+ flush();
42
+ const ordered = ORDERED.test(line);
43
+ const re = ordered ? ORDERED : BULLET;
44
+ const items = [];
45
+ while (i < lines.length && re.test(lines[i])) {
46
+ items.push(listItem(lines[i].replace(re, "")));
47
+ i++;
48
+ }
49
+ content.push({ type: ordered ? "orderedList" : "bulletList", content: items });
50
+ continue;
51
+ }
52
+ if (QUOTE.test(line)) {
53
+ flush();
54
+ const paras = [];
55
+ while (i < lines.length && QUOTE.test(lines[i])) {
56
+ paras.push(paragraph([lines[i].replace(QUOTE, "")]));
57
+ i++;
58
+ }
59
+ content.push({ type: "blockquote", content: paras });
60
+ continue;
61
+ }
62
+ para.push(line);
63
+ i++;
64
+ }
65
+ flush();
66
+ return { type: "doc", content };
67
+ }
68
+ /** Inline text of a node: text runs joined, a hard break as a newline, marks ignored. */
69
+ function inline(node) {
70
+ if (node.type === "text")
71
+ return node.text ?? "";
72
+ if (node.type === "hardBreak")
73
+ return "\n";
74
+ return (node.content ?? []).map(inline).join("");
75
+ }
76
+ /** Inline text of a node, with hard breaks flattened to spaces so the line syntax stays intact. A hard break cannot survive inside a prefixed line (list item, blockquote, or heading), so it flattens to a space (DESIGN.md §3 Richtext records the loss). */
77
+ function oneLine(node) {
78
+ return inline(node).replace(/\n/g, " ");
79
+ }
80
+ const itemText = (li) => (li.content ?? []).map(oneLine).join(" ");
81
+ function block(node) {
82
+ switch (node.type) {
83
+ case "heading": {
84
+ const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
85
+ return `${"#".repeat(level)} ${oneLine(node)}`;
86
+ }
87
+ case "bulletList": return (node.content ?? []).map((li) => `- ${itemText(li)}`).join("\n");
88
+ case "orderedList": return (node.content ?? []).map((li, i) => `${i + 1}. ${itemText(li)}`).join("\n");
89
+ case "blockquote": return (node.content ?? []).map((p) => `> ${oneLine(p)}`).join("\n");
90
+ default: return inline(node);
91
+ }
92
+ }
93
+ export function richtextToPlain(doc) {
94
+ const d = doc;
95
+ if (!d || typeof d !== "object" || d.type !== "doc" || !Array.isArray(d.content))
96
+ return "";
97
+ return d.content.map(block).filter((b) => b !== "").join("\n\n");
17
98
  }
@@ -3,6 +3,10 @@ import type { ZonePolicy } from "../zones.ts";
3
3
  export type AdminField = Record<string, unknown> & {
4
4
  type: string;
5
5
  target?: string;
6
+ /** An object's subfields, serialized. */
7
+ fields?: Record<string, AdminField>;
8
+ /** A list's item descriptor, serialized. */
9
+ item?: AdminField;
6
10
  };
7
11
  export type AdminSection = {
8
12
  name: string;
@@ -10,6 +10,9 @@ function serializeDescriptor(d) {
10
10
  else if (key === "fields" && d.type === "object") {
11
11
  out.fields = serializeMap(value);
12
12
  }
13
+ else if (key === "item" && d.type === "list") {
14
+ out.item = serializeDescriptor(value);
15
+ }
13
16
  else if (typeof value !== "function") {
14
17
  out[key] = value;
15
18
  }
@@ -223,7 +223,7 @@ export function EntryForm({ ops, registry, collection, id }) {
223
223
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
224
224
  const shared = Object.entries(meta.fields).filter(([key]) => !localeKeys.includes(key));
225
225
  const own = Object.entries(meta.fields).filter(([key]) => localeKeys.includes(key));
226
- const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
226
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
227
227
  const absent = !isNew && record !== null && fields === null && !record.locales[locale];
228
228
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [{ label: meta.title, href: `${BASE}/${collection}` }, { label: heading }] }), multilingual && !isNew && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })), multilingual && isNew && _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => switchLocale(entrySourceLocale(record, registry)), children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
229
229
  display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px",
@@ -135,7 +135,7 @@ export function SharedForm({ ops, registry, name }) {
135
135
  const own = Object.entries(item.fields).filter(([key]) => localeKeys.includes(key));
136
136
  const styles = (fields?.[STYLES_KEY] ?? {});
137
137
  const pages = new Set(usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId));
138
- const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
138
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
139
139
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [{ label: "Shared content", href: `${BASE}/shared` }, { label: item.title }] }), multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined }))] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] })), item.section !== undefined && Object.keys(item.styles).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), Object.entries(item.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], onChange: (value) => {
140
140
  setFields((f) => ({ ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } }));
141
141
  setDirty(true);