smoodly 0.0.9 → 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 +94 -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.js +4 -22
- 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/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/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 +8 -24
- 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 +3 -21
- 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 +27 -1
- package/dist/admin/validate.js +10 -0
- package/dist/collections.d.ts +0 -14
- package/dist/collections.js +0 -10
- package/dist/fields.d.ts +2 -1
- package/dist/fields.js +2 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- 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 -16
- package/dist/admin/richtext.js +0 -98
package/dist/admin/ui/theme.js
CHANGED
|
@@ -74,7 +74,7 @@ const SCALE = `
|
|
|
74
74
|
|
|
75
75
|
--radius-xs:1px; --radius-sm:3px; --radius-md:4px; --radius-round:999px;
|
|
76
76
|
--control-h:28px; --control-h-lg:36px; --row-h:38px; --topbar-h:48px;
|
|
77
|
-
--nav-w:200px; --inspector-w:
|
|
77
|
+
--nav-w:200px; --inspector-w:380px;
|
|
78
78
|
--border:1px solid var(--line);
|
|
79
79
|
--outline-selection:1.5px solid var(--selection);
|
|
80
80
|
`;
|
|
@@ -231,6 +231,32 @@ body:has(.sm-admin){margin:0}
|
|
|
231
231
|
.sm-inputwrap>.sm-input:hover,.sm-inputwrap>.sm-input:focus{background:transparent;box-shadow:none}
|
|
232
232
|
.sm-prefix{color:var(--text-muted);font-family:var(--font-mono);font-size:var(--text-sm);flex:none}
|
|
233
233
|
.sm-error{color:var(--danger);font-size:var(--text-sm)}
|
|
234
|
+
/* ── richtext ──────────────────────────────────────────────────────── */
|
|
235
|
+
.sm-richtext{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
236
|
+
.sm-richtext__bar{display:flex;flex-wrap:wrap;align-items:center;gap:2px}
|
|
237
|
+
.sm-richtext__sep{width:1px;height:16px;margin:0 4px;background:var(--line)}
|
|
238
|
+
.sm-richtext__bar .sm-iconbtn[aria-pressed="true"]{background:var(--press);color:var(--text)}
|
|
239
|
+
.sm-richtext__link{display:flex;align-items:center;gap:6px}
|
|
240
|
+
.sm-richtext__content{
|
|
241
|
+
min-height:110px;padding:8px 10px;border:1.5px solid transparent;border-radius:var(--radius-md);
|
|
242
|
+
background:var(--surface-inset);color:var(--text);font-size:var(--text-md);line-height:1.5;outline:0;
|
|
243
|
+
}
|
|
244
|
+
.sm-richtext__content:hover{background:var(--control-hover)}
|
|
245
|
+
.sm-richtext__content:focus{background:var(--input-bg);border-color:var(--selection);box-shadow:var(--focus-ring)}
|
|
246
|
+
.sm-richtext__content>*+*{margin-top:.5em}
|
|
247
|
+
.sm-richtext__content p,.sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,
|
|
248
|
+
.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6,
|
|
249
|
+
.sm-richtext__content ul,.sm-richtext__content ol,.sm-richtext__content blockquote{margin:0}
|
|
250
|
+
.sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,
|
|
251
|
+
.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-weight:600;line-height:1.25}
|
|
252
|
+
.sm-richtext__content h1{font-size:var(--text-3xl)}
|
|
253
|
+
.sm-richtext__content h2{font-size:var(--text-2xl)}
|
|
254
|
+
.sm-richtext__content h3{font-size:var(--text-xl)}
|
|
255
|
+
.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-size:var(--text-lg)}
|
|
256
|
+
.sm-richtext__content ul,.sm-richtext__content ol{padding-left:18px}
|
|
257
|
+
.sm-richtext__content li+li{margin-top:.25em}
|
|
258
|
+
.sm-richtext__content blockquote{padding-left:10px;border-left:2px solid var(--line);color:var(--text-muted)}
|
|
259
|
+
.sm-richtext__content a{color:var(--accent);text-decoration:underline}
|
|
234
260
|
|
|
235
261
|
.sm-group{display:flex;flex-direction:column;gap:10px;padding:2px 0 0 10px;border-left:2px solid var(--line)}
|
|
236
262
|
.sm-list{display:flex;flex-direction:column;gap:6px;align-items:stretch}
|
package/dist/admin/validate.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// MVP validation, keyed by inert descriptor data (the design rule: logic
|
|
2
|
+
// lives admin/server-side). Entry saves validate; page drafts do NOT
|
|
3
|
+
// (drafts must save freely) — publish validates every node. Objects and
|
|
4
|
+
// lists recurse; error paths are dotted (`cta.href`, `points.2.heading`).
|
|
5
|
+
import { hasText } from "./richtext-doc.js";
|
|
1
6
|
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
2
7
|
const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
|
|
3
8
|
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
@@ -11,6 +16,11 @@ function validateValue(path, value, d, errors) {
|
|
|
11
16
|
errors.push({ field: path, message: "Required." });
|
|
12
17
|
return;
|
|
13
18
|
}
|
|
19
|
+
if (d.type === "richtext" && !hasText(value)) {
|
|
20
|
+
if (!mayBeAbsent(d))
|
|
21
|
+
errors.push({ field: path, message: "Required." });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
14
24
|
if (d.type === "list") {
|
|
15
25
|
if (!Array.isArray(value)) {
|
|
16
26
|
errors.push({ field: path, message: "Must be a list." });
|
package/dist/collections.d.ts
CHANGED
|
@@ -53,17 +53,3 @@ export declare function collection<F extends BuilderMap>(input: {
|
|
|
53
53
|
};
|
|
54
54
|
versions?: boolean;
|
|
55
55
|
}): CollectionSchema<F>;
|
|
56
|
-
export type Toolset = {
|
|
57
|
-
marks?: string[];
|
|
58
|
-
nodes?: string[];
|
|
59
|
-
elements?: string[];
|
|
60
|
-
};
|
|
61
|
-
export declare function toolset(input: {
|
|
62
|
-
marks?: string[];
|
|
63
|
-
nodes?: string[];
|
|
64
|
-
elements?: {
|
|
65
|
-
schema: {
|
|
66
|
-
name: string;
|
|
67
|
-
};
|
|
68
|
-
}[];
|
|
69
|
-
}): Toolset;
|
package/dist/collections.js
CHANGED
|
@@ -45,13 +45,3 @@ export function collection(input) {
|
|
|
45
45
|
admin: input.admin,
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
export function toolset(input) {
|
|
49
|
-
const out = {};
|
|
50
|
-
if (input.marks)
|
|
51
|
-
out.marks = input.marks;
|
|
52
|
-
if (input.nodes)
|
|
53
|
-
out.nodes = input.nodes;
|
|
54
|
-
if (input.elements)
|
|
55
|
-
out.elements = input.elements.map((e) => e.schema.name);
|
|
56
|
-
return out;
|
|
57
|
-
}
|
package/dist/fields.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Toolset } from "./toolset.ts";
|
|
1
2
|
export type Descriptor = {
|
|
2
3
|
type: string;
|
|
3
4
|
[key: string]: unknown;
|
|
@@ -59,7 +60,7 @@ export declare const f: {
|
|
|
59
60
|
url: string;
|
|
60
61
|
}, {}>;
|
|
61
62
|
richtext: (opts?: {
|
|
62
|
-
toolset?:
|
|
63
|
+
toolset?: Toolset;
|
|
63
64
|
}) => FieldBuilder<unknown, {}>;
|
|
64
65
|
dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
|
|
65
66
|
select: <const O extends readonly string[]>(options: O) => FieldBuilder<O[number], {}>;
|
package/dist/fields.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Field builders — inert descriptors by design rule (see DESIGN.md §3):
|
|
2
2
|
// f.text().max(40) is plain data; validation logic lives server/admin-side,
|
|
3
3
|
// keyed by descriptor type. Chaining is immutable.
|
|
4
|
+
import { DEFAULT_TOOLSET } from "./toolset.js";
|
|
4
5
|
function builder(descriptor) {
|
|
5
6
|
const chain = (patch) => builder({ ...descriptor, ...patch });
|
|
6
7
|
return {
|
|
@@ -58,7 +59,7 @@ export const f = {
|
|
|
58
59
|
image: () => builder({ type: "image" }),
|
|
59
60
|
video: () => builder({ type: "video" }),
|
|
60
61
|
file: () => builder({ type: "file" }),
|
|
61
|
-
richtext: (opts) => builder({ type: "richtext",
|
|
62
|
+
richtext: (opts) => builder({ type: "richtext", toolset: opts?.toolset ?? DEFAULT_TOOLSET }),
|
|
62
63
|
dataAttributes: () => builder({ type: "dataAttributes" }),
|
|
63
64
|
select: (options) => builder({ type: "select", options: [...options] }),
|
|
64
65
|
object: (shape) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { section, element } from "./pairing.tsx";
|
|
2
|
-
import { collection
|
|
2
|
+
import { collection } from "./collections.ts";
|
|
3
|
+
import { toolset } from "./toolset.ts";
|
|
3
4
|
import { Section } from "./section-wrapper.tsx";
|
|
4
5
|
import { page } from "./render.tsx";
|
|
5
6
|
import { entryPage } from "./entry-page.tsx";
|
|
@@ -39,7 +40,8 @@ export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
|
39
40
|
export type { FieldBuilder, Descriptor, ValueOf } from "./fields.ts";
|
|
40
41
|
export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
|
|
41
42
|
export type { Props, PairedComponent } from "./pairing.tsx";
|
|
42
|
-
export type { CollectionSchema,
|
|
43
|
+
export type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
44
|
+
export { DEFAULT_TOOLSET, type Toolset, type ToolsetMark, type ToolsetNode } from "./toolset.ts";
|
|
43
45
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.ts";
|
|
44
46
|
export type { ZonePolicy } from "./zones.ts";
|
|
45
47
|
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
@@ -26,6 +27,7 @@ export { resolveTree, resolveFields } from "./resolve.js";
|
|
|
26
27
|
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
|
|
27
28
|
export { fixedPathFor } from "./localize.js";
|
|
28
29
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
30
|
+
export { DEFAULT_TOOLSET } from "./toolset.js";
|
|
29
31
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
30
32
|
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.js";
|
|
31
33
|
export { sharedEntryId } from "./shared-id.js";
|
|
@@ -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,16 +0,0 @@
|
|
|
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
|
-
}[];
|
|
10
|
-
};
|
|
11
|
-
export type RichtextDoc = {
|
|
12
|
-
type: "doc";
|
|
13
|
-
content: RichtextNode[];
|
|
14
|
-
};
|
|
15
|
-
export declare function plainToRichtext(text: string): RichtextDoc;
|
|
16
|
-
export declare function richtextToPlain(doc: unknown): string;
|
package/dist/admin/richtext.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
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" };
|
|
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 = /^> ?/;
|
|
16
|
-
export function plainToRichtext(text) {
|
|
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 = [];
|
|
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");
|
|
98
|
-
}
|