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 +145 -11
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +85 -7
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- package/dist/admin/richtext.d.ts +13 -11
- package/dist/admin/richtext.js +94 -13
- package/dist/admin/serialize.d.ts +4 -0
- package/dist/admin/serialize.js +3 -0
- package/dist/admin/shell/EntryForm.js +1 -1
- package/dist/admin/shell/SharedForm.js +1 -1
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +13 -0
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +43 -13
- package/dist/entry-store.js +7 -1
- package/dist/fields.d.ts +16 -1
- package/dist/fields.js +49 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/refs.js +5 -0
- package/dist/resolve.js +3 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -0
- package/dist/sql-space.d.ts +4 -0
- package/dist/sql-space.js +5 -1
- package/dist/sql.js +1 -1
- package/dist/supabase-entry-store.d.ts +8 -0
- package/dist/supabase-entry-store.js +37 -2
- package/package.json +1 -1
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
|
-
|
|
318
|
-
|
|
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
|
-
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
2
|
import type { AdminField } from "../serialize.ts";
|
|
3
|
-
export declare function FieldWidget({ descriptor: d, value, onChange,
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
52
|
-
// press of the first
|
|
53
|
-
return (_jsx(Field, { label: label(fieldKey), error:
|
|
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
|
+
}
|
package/dist/admin/richtext.d.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
type
|
|
2
|
-
type:
|
|
3
|
-
|
|
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
|
|
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:
|
|
13
|
+
content: RichtextNode[];
|
|
13
14
|
};
|
|
14
|
-
export
|
|
15
|
+
export declare function plainToRichtext(text: string): RichtextDoc;
|
|
16
|
+
export declare function richtextToPlain(doc: unknown): string;
|
package/dist/admin/richtext.js
CHANGED
|
@@ -1,17 +1,98 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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;
|
package/dist/admin/serialize.js
CHANGED
|
@@ -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],
|
|
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],
|
|
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);
|