smoodly 0.0.8 → 0.0.10
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 +228 -18
- package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
- package/dist/admin/editor/CanvasOverlay.js +39 -0
- package/dist/admin/editor/EditorView.js +56 -237
- package/dist/admin/editor/InsertPicker.d.ts +18 -0
- package/dist/admin/editor/InsertPicker.js +18 -0
- package/dist/admin/editor/NoticePane.d.ts +4 -0
- package/dist/admin/editor/NoticePane.js +4 -0
- package/dist/admin/editor/Outline.d.ts +18 -0
- package/dist/admin/editor/Outline.js +12 -0
- package/dist/admin/editor/locale-url.d.ts +12 -0
- package/dist/admin/editor/locale-url.js +32 -0
- package/dist/admin/editor/overlay-geometry.d.ts +14 -0
- package/dist/admin/editor/overlay-geometry.js +27 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
- package/dist/admin/editor/useCanvasBridge.js +53 -0
- package/dist/admin/editor/usePageLocale.d.ts +23 -0
- package/dist/admin/editor/usePageLocale.js +88 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +68 -8
- package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
- package/dist/admin/forms/RichtextToolbar.js +37 -0
- package/dist/admin/forms/RichtextWidget.d.ts +5 -0
- package/dist/admin/forms/RichtextWidget.js +123 -0
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- package/dist/admin/forms/richtext-extensions.d.ts +5 -0
- package/dist/admin/forms/richtext-extensions.js +74 -0
- package/dist/admin/forms/richtext-paste.d.ts +22 -0
- package/dist/admin/forms/richtext-paste.js +59 -0
- package/dist/admin/index.d.ts +0 -1
- package/dist/admin/index.js +0 -1
- package/dist/admin/next/create-admin.d.ts +3 -7
- package/dist/admin/next/create-admin.js +14 -5
- package/dist/admin/richtext-doc.d.ts +7 -0
- package/dist/admin/richtext-doc.js +18 -0
- package/dist/admin/serialize.d.ts +4 -0
- package/dist/admin/serialize.js +3 -0
- package/dist/admin/shell/AdminApp.d.ts +7 -2
- package/dist/admin/shell/AdminApp.js +12 -6
- package/dist/admin/shell/EntriesList.js +3 -2
- package/dist/admin/shell/EntryForm.js +9 -25
- package/dist/admin/shell/ListView.js +3 -1
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/RouteLink.d.ts +4 -0
- package/dist/admin/shell/RouteLink.js +12 -0
- package/dist/admin/shell/SharedForm.js +4 -22
- package/dist/admin/shell/router.d.ts +37 -0
- package/dist/admin/shell/router.js +84 -0
- package/dist/admin/shell/session.js +3 -1
- package/dist/admin/ui/primitives.d.ts +2 -1
- package/dist/admin/ui/primitives.js +5 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +40 -1
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +53 -13
- package/dist/collections.d.ts +0 -14
- package/dist/collections.js +0 -10
- package/dist/entry-store.js +7 -1
- package/dist/fields.d.ts +18 -2
- package/dist/fields.js +51 -5
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- 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/dist/toolset.d.ts +30 -0
- package/dist/toolset.js +49 -0
- package/package.json +11 -1
- package/dist/admin/richtext.d.ts +0 -14
- package/dist/admin/richtext.js +0 -17
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { schema } from "./schema.js";
|
|
2
2
|
import { section, element } from "./pairing.js";
|
|
3
|
-
import { collection
|
|
3
|
+
import { collection } from "./collections.js";
|
|
4
|
+
import { toolset } from "./toolset.js";
|
|
4
5
|
import { Section } from "./section-wrapper.js";
|
|
5
6
|
import { page } from "./render.js";
|
|
6
7
|
import { entryPage } from "./entry-page.js";
|
|
7
8
|
import { shared } from "./shared.js";
|
|
8
9
|
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.js";
|
|
10
|
+
export { RichText } from "./richtext-render.js";
|
|
9
11
|
export { fixedSlug } from "./page.js";
|
|
10
12
|
export { f } from "./fields.js";
|
|
11
13
|
export { z } from "./zones.js";
|
|
@@ -25,8 +27,11 @@ export { resolveTree, resolveFields } from "./resolve.js";
|
|
|
25
27
|
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
|
|
26
28
|
export { fixedPathFor } from "./localize.js";
|
|
27
29
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
30
|
+
export { DEFAULT_TOOLSET } from "./toolset.js";
|
|
28
31
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
29
32
|
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.js";
|
|
33
|
+
export { sharedEntryId } from "./shared-id.js";
|
|
34
|
+
export { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
30
35
|
export const smoodly = {
|
|
31
36
|
schema,
|
|
32
37
|
section,
|
package/dist/refs.js
CHANGED
|
@@ -25,6 +25,11 @@ function collectFrom(values, descriptors, out) {
|
|
|
25
25
|
out.push({ field, targetCollection: targetOf(d), targetId: id });
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
else if (d.type === "object" || d.type === "list") {
|
|
29
|
+
// Deliberate: refs are top-level only — f.list/f.object refuse them at
|
|
30
|
+
// build time (spec 2026-09-07, list field §1), so there is nothing to walk.
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
export function collectEntryRefs(fields, schema) {
|
package/dist/resolve.js
CHANGED
|
@@ -174,5 +174,8 @@ function collectTasks(values, descriptors, out) {
|
|
|
174
174
|
else if (d.type === "refList" && Array.isArray(value)) {
|
|
175
175
|
out.push({ holder: values, key, collection: targetOf(d), id: value, isList: true, mode: modeOf(d), ancestors: [] });
|
|
176
176
|
}
|
|
177
|
+
else if (d.type === "object" || d.type === "list") {
|
|
178
|
+
continue; // refs are top-level only; containers hold none (spec 2026-09-07)
|
|
179
|
+
}
|
|
177
180
|
}
|
|
178
181
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// The site-side richtext renderer (spec 2026-09-07, list field §5):
|
|
3
|
+
// TipTap JSON → React, a fragment with no wrapper so the developer owns
|
|
4
|
+
// the surrounding element and its class. Pure React, no Node imports, so
|
|
5
|
+
// it works in shared, server and client components alike. Unknown nodes
|
|
6
|
+
// render their children — a document from a later, richer editor degrades
|
|
7
|
+
// instead of crashing. Elements as node views are Phase 3.
|
|
8
|
+
import { Fragment, createElement } from "react";
|
|
9
|
+
/** Only schemes an editor's link may carry reach the page: http(s), mailto, tel,
|
|
10
|
+
* and site-relative targets. Anything else (javascript:, data:, an empty href,
|
|
11
|
+
* a protocol-relative `//host` or a backslash form `/\host` that a browser
|
|
12
|
+
* treats the same way) renders the text without an anchor. */
|
|
13
|
+
const SAFE_HREF = /^(https?:|mailto:|tel:|\/(?![/\\])|#|\?|\.)/i;
|
|
14
|
+
function safeHref(raw) {
|
|
15
|
+
if (typeof raw !== "string")
|
|
16
|
+
return null;
|
|
17
|
+
// URL parsers drop tab, LF and CR, so normalise before testing to see what the parser sees.
|
|
18
|
+
const href = raw.replace(/[\t\n\r]/g, "").trim();
|
|
19
|
+
return href !== "" && SAFE_HREF.test(href) ? href : null;
|
|
20
|
+
}
|
|
21
|
+
export function RichText({ doc }) {
|
|
22
|
+
const d = doc;
|
|
23
|
+
if (!d || typeof d !== "object" || d.type !== "doc" || !Array.isArray(d.content))
|
|
24
|
+
return null;
|
|
25
|
+
return _jsx(_Fragment, { children: renderNodes(d.content) });
|
|
26
|
+
}
|
|
27
|
+
function renderNodes(nodes) {
|
|
28
|
+
return nodes.map((n, i) => _jsx(Fragment, { children: renderNode(n) }, i));
|
|
29
|
+
}
|
|
30
|
+
function renderNode(node) {
|
|
31
|
+
if (!node || typeof node !== "object")
|
|
32
|
+
return null;
|
|
33
|
+
const children = Array.isArray(node.content) ? renderNodes(node.content) : null;
|
|
34
|
+
switch (node.type) {
|
|
35
|
+
case "text": return withMarks(node.text ?? "", node.marks ?? []);
|
|
36
|
+
case "paragraph": return _jsx("p", { children: children });
|
|
37
|
+
case "heading": {
|
|
38
|
+
const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
|
|
39
|
+
return createElement(`h${level}`, null, children);
|
|
40
|
+
}
|
|
41
|
+
case "bulletList": return _jsx("ul", { children: children });
|
|
42
|
+
case "orderedList": return _jsx("ol", { children: children });
|
|
43
|
+
case "listItem": return _jsx("li", { children: children });
|
|
44
|
+
case "blockquote": return _jsx("blockquote", { children: children });
|
|
45
|
+
case "hardBreak": return _jsx("br", {});
|
|
46
|
+
default: return children;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Marks wrap from the first outward: [bold, italic] → <em><strong>…</strong></em>. */
|
|
50
|
+
function withMarks(text, marks) {
|
|
51
|
+
return marks.reduce((inner, m) => {
|
|
52
|
+
switch (m.type) {
|
|
53
|
+
case "bold": return _jsx("strong", { children: inner });
|
|
54
|
+
case "italic": return _jsx("em", { children: inner });
|
|
55
|
+
case "link": {
|
|
56
|
+
const href = safeHref(m.attrs?.href);
|
|
57
|
+
return href === null ? inner : _jsx("a", { href: href, children: inner });
|
|
58
|
+
}
|
|
59
|
+
default: return inner;
|
|
60
|
+
}
|
|
61
|
+
}, text);
|
|
62
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The row id of a shared item (spec 2026-09-07 §10 "§3 row id"): uuid v5
|
|
2
|
+
// of (space id as the namespace, item name as the name). Deterministic in
|
|
3
|
+
// both adapters, so a second concurrent materialization of the same item
|
|
4
|
+
// hits the `entries` primary key instead of adding an unremovable
|
|
5
|
+
// duplicate — `delete` refuses every shared row. The space is part of it
|
|
6
|
+
// because the primary key is `id` alone and, in the cloud, many spaces
|
|
7
|
+
// share one table: a name-only id would make space B's footer collide
|
|
8
|
+
// with space A's.
|
|
9
|
+
//
|
|
10
|
+
// No imports — Web Crypto (`globalThis.crypto.subtle`) exists in Node
|
|
11
|
+
// >= 20 and in every browser, so this module carries no node builtin
|
|
12
|
+
// into the package root's graph. It matters because every scaffolded
|
|
13
|
+
// section does `import { f, smoodly } from "smoodly"`, and a "use client"
|
|
14
|
+
// section's browser build would fail on `node:crypto`.
|
|
15
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
/** The namespace's 16 raw bytes — RFC 4122 hashes the bytes, not the text. */
|
|
17
|
+
function uuidBytes(uuid) {
|
|
18
|
+
if (!UUID_RE.test(uuid))
|
|
19
|
+
throw new Error(`smoodly: "${uuid}" is not a uuid — a shared item's namespace is the space id.`);
|
|
20
|
+
const hex = uuid.replace(/-/g, "");
|
|
21
|
+
const bytes = new Uint8Array(16);
|
|
22
|
+
for (let i = 0; i < 16; i++)
|
|
23
|
+
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
24
|
+
return bytes;
|
|
25
|
+
}
|
|
26
|
+
/** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
|
|
27
|
+
* variant 10xx, first 16 bytes of the digest. */
|
|
28
|
+
export async function sharedEntryId(space, name) {
|
|
29
|
+
const input = new Uint8Array([...uuidBytes(space), ...new TextEncoder().encode(name)]);
|
|
30
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", input));
|
|
31
|
+
const bytes = digest.subarray(0, 16);
|
|
32
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5 in the high nibble of octet 6
|
|
33
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx in the top bits of octet 8
|
|
34
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
36
|
+
}
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** The one space a self-host install has; the space a request with no
|
|
2
|
+
* claim resolves to. The bare uuid — code needs the value (a shared
|
|
3
|
+
* item's id namespace), SQL needs it quoted. */
|
|
4
|
+
export declare const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
|
|
1
5
|
export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
|
|
2
6
|
export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0000-0000-0000-000000000000'";
|
|
3
7
|
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths"];
|
package/dist/sql-space.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// is one cloud environment; a self-host install is one space with the
|
|
3
3
|
// default id. Everything that needs "which space is this request in"
|
|
4
4
|
// reads smoodly_current_space() — the adapters never mention spaces.
|
|
5
|
-
|
|
5
|
+
/** The one space a self-host install has; the space a request with no
|
|
6
|
+
* claim resolves to. The bare uuid — code needs the value (a shared
|
|
7
|
+
* item's id namespace), SQL needs it quoted. */
|
|
8
|
+
export const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
|
|
9
|
+
export const DEFAULT_SPACE = `'${DEFAULT_SPACE_ID}'`;
|
|
6
10
|
export const SPACE_COL = `"space_id" uuid not null default ${DEFAULT_SPACE}`;
|
|
7
11
|
export const CONTENT_TABLES = [
|
|
8
12
|
"pages",
|
package/dist/sql.js
CHANGED
|
@@ -31,7 +31,7 @@ function fieldExpr(key, d, source) {
|
|
|
31
31
|
case "date":
|
|
32
32
|
return `(${source}->>'${key}')::timestamptz`;
|
|
33
33
|
default:
|
|
34
|
-
return `${source}->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
|
|
34
|
+
return `${source}->'${key}'`; // image, video, file, object, list, richtext, dataAttributes, refList
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
export function collectionSQL(collection) {
|
|
@@ -6,9 +6,17 @@ export declare class SupabaseEntryStore implements EntryStore {
|
|
|
6
6
|
private paths;
|
|
7
7
|
private locales;
|
|
8
8
|
private schemas;
|
|
9
|
+
private spaceId?;
|
|
9
10
|
constructor(db: SupabaseClient, collections: CollectionSchema<any>[], options?: EntryStoreOptions);
|
|
10
11
|
private fail;
|
|
11
12
|
private schema;
|
|
13
|
+
/** The space this client acts in — the ONE function the policies and
|
|
14
|
+
* the insert trigger read (src/sql-space.ts): a key's claim in the
|
|
15
|
+
* cloud, the default space under the service role. Constant for the
|
|
16
|
+
* life of the client, so the PROMISE is cached and concurrent creates
|
|
17
|
+
* share one round trip; a failed lookup is not cached. */
|
|
18
|
+
private space;
|
|
19
|
+
private fetchSpace;
|
|
12
20
|
private fetch;
|
|
13
21
|
private must;
|
|
14
22
|
private siblings;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// untouched.
|
|
9
9
|
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
10
10
|
import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
|
|
11
|
+
import { sharedEntryId } from "./shared-id.js";
|
|
11
12
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
12
13
|
import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
|
|
13
14
|
// entries.id is a Postgres uuid column: filtering on a non-UUID string
|
|
@@ -54,6 +55,29 @@ export class SupabaseEntryStore {
|
|
|
54
55
|
schema(collection) {
|
|
55
56
|
return schemaOf(this.schemas, collection);
|
|
56
57
|
}
|
|
58
|
+
/** The space this client acts in — the ONE function the policies and
|
|
59
|
+
* the insert trigger read (src/sql-space.ts): a key's claim in the
|
|
60
|
+
* cloud, the default space under the service role. Constant for the
|
|
61
|
+
* life of the client, so the PROMISE is cached and concurrent creates
|
|
62
|
+
* share one round trip; a failed lookup is not cached. */
|
|
63
|
+
space() {
|
|
64
|
+
if (!this.spaceId) {
|
|
65
|
+
this.spaceId = this.fetchSpace().catch((e) => {
|
|
66
|
+
// A failed lookup is not cached: the next write retries it.
|
|
67
|
+
this.spaceId = undefined;
|
|
68
|
+
throw e;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return this.spaceId;
|
|
72
|
+
}
|
|
73
|
+
async fetchSpace() {
|
|
74
|
+
const res = await this.db.rpc("smoodly_current_space");
|
|
75
|
+
if (res.error)
|
|
76
|
+
this.fail(res.error.message);
|
|
77
|
+
if (typeof res.data !== "string")
|
|
78
|
+
this.fail("smoodly_current_space() returned no space — is the schema up to date?");
|
|
79
|
+
return res.data;
|
|
80
|
+
}
|
|
57
81
|
// ── reads ──
|
|
58
82
|
async fetch(collection, id) {
|
|
59
83
|
if (!UUID_RE.test(id))
|
|
@@ -253,9 +277,20 @@ export class SupabaseEntryStore {
|
|
|
253
277
|
this.assertParentHasLocale(parent, options.locale);
|
|
254
278
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
255
279
|
await this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
256
|
-
|
|
257
|
-
|
|
280
|
+
// A shared item's row id is DERIVED from (space, name), not generated
|
|
281
|
+
// (spec 2026-09-07 §10): the SELECT above is a fast path two racing
|
|
282
|
+
// materializations can both pass, so the primary key is the backstop
|
|
283
|
+
// — a duplicate would be unremovable, since `delete` refuses every
|
|
284
|
+
// shared row.
|
|
285
|
+
const row = { collection, parent_id: parentId, fields: shared };
|
|
286
|
+
if (isSharedSchema(schema))
|
|
287
|
+
row.id = await sharedEntryId(await this.space(), collection);
|
|
288
|
+
const inserted = await this.db.from("entries").insert(row).select("id").single();
|
|
289
|
+
if (inserted.error) {
|
|
290
|
+
if (isSharedSchema(schema) && inserted.error.code === UNIQUE_VIOLATION)
|
|
291
|
+
throw entryErrors.singleton(collection);
|
|
258
292
|
this.fail(inserted.error.message);
|
|
293
|
+
}
|
|
259
294
|
const id = inserted.data.id;
|
|
260
295
|
try {
|
|
261
296
|
await this.insertLocale(id, options.locale, { slug, fields: localized });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const TOOLSET_MARKS: readonly ["bold", "italic", "link"];
|
|
2
|
+
export declare const TOOLSET_NODES: readonly ["h1", "h2", "h3", "h4", "h5", "h6", "bulletList", "orderedList", "blockquote"];
|
|
3
|
+
export type ToolsetMark = (typeof TOOLSET_MARKS)[number];
|
|
4
|
+
export type ToolsetNode = (typeof TOOLSET_NODES)[number];
|
|
5
|
+
export type Toolset = {
|
|
6
|
+
marks?: ToolsetMark[];
|
|
7
|
+
nodes?: ToolsetNode[];
|
|
8
|
+
elements?: string[];
|
|
9
|
+
};
|
|
10
|
+
/** What a richtext field gets when it declares nothing. `h1` is left out:
|
|
11
|
+
* the section or page shape owns the page's first heading. */
|
|
12
|
+
export declare const DEFAULT_TOOLSET: Toolset;
|
|
13
|
+
export declare function toolset(input: {
|
|
14
|
+
marks?: readonly ToolsetMark[];
|
|
15
|
+
nodes?: readonly ToolsetNode[];
|
|
16
|
+
elements?: {
|
|
17
|
+
schema: {
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
}[];
|
|
21
|
+
}): Toolset;
|
|
22
|
+
/** The resolved toolset the widget reads: which marks and nodes an editor
|
|
23
|
+
* may CREATE. The schema itself is always the full one (spec §2). */
|
|
24
|
+
export type Allowed = {
|
|
25
|
+
marks: ReadonlySet<ToolsetMark>;
|
|
26
|
+
nodes: ReadonlySet<ToolsetNode>;
|
|
27
|
+
/** The allowed heading levels, ascending. */
|
|
28
|
+
headingLevels: number[];
|
|
29
|
+
};
|
|
30
|
+
export declare function allowedOf(input: unknown): Allowed;
|
package/dist/toolset.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Toolsets are plain shared values, not registrations (DESIGN.md §3): a
|
|
2
|
+
// richtext field imports one by value and it travels inside the field
|
|
3
|
+
// descriptor to the admin. The vocabulary is typed and checked at
|
|
4
|
+
// registration; a field without a toolset carries the default. `Allowed`
|
|
5
|
+
// is the resolved form the widget reads (spec 2026-09-08 §1).
|
|
6
|
+
export const TOOLSET_MARKS = ["bold", "italic", "link"];
|
|
7
|
+
export const TOOLSET_NODES = ["h1", "h2", "h3", "h4", "h5", "h6", "bulletList", "orderedList", "blockquote"];
|
|
8
|
+
/** What a richtext field gets when it declares nothing. `h1` is left out:
|
|
9
|
+
* the section or page shape owns the page's first heading. */
|
|
10
|
+
export const DEFAULT_TOOLSET = {
|
|
11
|
+
marks: ["bold", "italic", "link"],
|
|
12
|
+
nodes: ["h2", "h3", "h4", "bulletList", "orderedList", "blockquote"],
|
|
13
|
+
};
|
|
14
|
+
const isMark = (s) => TOOLSET_MARKS.includes(s);
|
|
15
|
+
const isNode = (s) => TOOLSET_NODES.includes(s);
|
|
16
|
+
export function toolset(input) {
|
|
17
|
+
const out = {};
|
|
18
|
+
if (input.marks) {
|
|
19
|
+
for (const m of input.marks) {
|
|
20
|
+
if (!isMark(m))
|
|
21
|
+
throw new Error(`smoodly: toolset mark "${m}" is not one of ${TOOLSET_MARKS.join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
out.marks = [...input.marks];
|
|
24
|
+
}
|
|
25
|
+
if (input.nodes) {
|
|
26
|
+
for (const n of input.nodes) {
|
|
27
|
+
if (!isNode(n))
|
|
28
|
+
throw new Error(`smoodly: toolset node "${n}" is not one of ${TOOLSET_NODES.join(", ")}.`);
|
|
29
|
+
}
|
|
30
|
+
out.nodes = [...input.nodes];
|
|
31
|
+
}
|
|
32
|
+
if (input.elements)
|
|
33
|
+
out.elements = input.elements.map((e) => e.schema.name);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
const HEADING = /^h([1-6])$/;
|
|
37
|
+
export function allowedOf(input) {
|
|
38
|
+
// A missing or malformed toolset is the default; a declared one is exact,
|
|
39
|
+
// so a group it leaves out allows nothing (a marks-only toolset has no headings).
|
|
40
|
+
const t = input && typeof input === "object" ? input : DEFAULT_TOOLSET;
|
|
41
|
+
const marks = Array.isArray(t.marks) ? t.marks.filter((m) => typeof m === "string" && isMark(m)) : [];
|
|
42
|
+
const nodes = Array.isArray(t.nodes) ? t.nodes.filter((n) => typeof n === "string" && isNode(n)) : [];
|
|
43
|
+
const headingLevels = nodes
|
|
44
|
+
.map((n) => HEADING.exec(n)?.[1])
|
|
45
|
+
.filter((l) => l !== undefined)
|
|
46
|
+
.map(Number)
|
|
47
|
+
.sort((a, b) => a - b);
|
|
48
|
+
return { marks: new Set(marks), nodes: new Set(nodes), headingLevels };
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smoodly",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
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": {
|
|
@@ -65,6 +65,16 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@supabase/supabase-js": "^2.113.0",
|
|
68
|
+
"@tiptap/core": "3.31.3",
|
|
69
|
+
"@tiptap/extension-blockquote": "3.31.3",
|
|
70
|
+
"@tiptap/extension-bold": "3.31.3",
|
|
71
|
+
"@tiptap/extension-heading": "3.31.3",
|
|
72
|
+
"@tiptap/extension-italic": "3.31.3",
|
|
73
|
+
"@tiptap/extension-link": "3.31.3",
|
|
74
|
+
"@tiptap/extension-list": "3.31.3",
|
|
75
|
+
"@tiptap/pm": "3.31.3",
|
|
76
|
+
"@tiptap/react": "3.31.3",
|
|
77
|
+
"@tiptap/starter-kit": "3.31.3",
|
|
68
78
|
"lucide-react": "^1.41.0"
|
|
69
79
|
}
|
|
70
80
|
}
|
package/dist/admin/richtext.d.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
type TextNode = {
|
|
2
|
-
type: "text";
|
|
3
|
-
text: string;
|
|
4
|
-
};
|
|
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
|
-
type: "doc";
|
|
12
|
-
content: Paragraph[];
|
|
13
|
-
};
|
|
14
|
-
export {};
|
package/dist/admin/richtext.js
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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");
|
|
8
|
-
}
|
|
9
|
-
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
|
-
})),
|
|
16
|
-
};
|
|
17
|
-
}
|