@wtfalch/design 0.11.0 → 0.12.0
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 +30 -0
- package/dist/components/RichText.d.ts +24 -0
- package/dist/components/RichText.js +69 -0
- package/dist/components/RichTextEditor.d.ts +32 -0
- package/dist/components/RichTextEditor.js +163 -0
- package/dist/editor.d.ts +16 -0
- package/dist/editor.js +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/rich-text/schema.d.ts +219 -0
- package/dist/rich-text/schema.js +88 -0
- package/dist/rich-text/value.d.ts +25 -0
- package/dist/rich-text/value.js +41 -0
- package/dist/rich-text.d.ts +11 -0
- package/dist/rich-text.js +11 -0
- package/dist/styles/index.css +111 -0
- package/dist/tf.css +111 -0
- package/dist/valet.css +111 -0
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -194,6 +194,36 @@ Icons and illustrations are the system's, shared by every product the way
|
|
|
194
194
|
`Button` is. A product wanting its own inside the package's components is a
|
|
195
195
|
case nobody has had; when it comes, the product entry is where to bind it.
|
|
196
196
|
|
|
197
|
+
## Rich text
|
|
198
|
+
|
|
199
|
+
Prose, written and drawn, added in `0.11.0` for the CMS and the forum.
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import { RichText, isEmptyRichText } from '@wtfalch/design' // drawing it
|
|
203
|
+
import { RichTextEditor } from '@wtfalch/design/editor' // writing it
|
|
204
|
+
import { richTextSchema } from '@wtfalch/design/rich-text' // storing it
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Three entries and not one, because they cost different things. Drawing prose
|
|
208
|
+
is a server component with no dependencies. Writing it loads TipTap, which is
|
|
209
|
+
ProseMirror, and a site that only reads should not download an editor.
|
|
210
|
+
Validating it needs zod. TipTap and zod are **optional peer dependencies**:
|
|
211
|
+
install them if you import those entries, and the front door works without
|
|
212
|
+
either.
|
|
213
|
+
|
|
214
|
+
**The restriction is the component.** The toolbar offers two heading levels,
|
|
215
|
+
bold, italic, a link and two kinds of list. `richTextSchema` admits exactly
|
|
216
|
+
those and `RichText` draws exactly those, so a document cannot contain
|
|
217
|
+
something a page cannot render — and a consumer that validates on the way
|
|
218
|
+
into its database gets that guarantee against a crafted request too, not just
|
|
219
|
+
against the toolbar. Tables, colours, fonts, code blocks and quotes are off,
|
|
220
|
+
each one a line in the component with the reason beside it. A seventh thing
|
|
221
|
+
is added in all three places, on purpose.
|
|
222
|
+
|
|
223
|
+
Nothing here produces an HTML string: `RichText` walks the value into React
|
|
224
|
+
elements, so there is no sanitiser to configure and none to get wrong. That
|
|
225
|
+
is the difference from `Markdown`, which parses and must sanitise.
|
|
226
|
+
|
|
197
227
|
## Status
|
|
198
228
|
|
|
199
229
|
`0.3.1`. Twenty-eight components, every one of the 70 gallery specimens
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RichTextValue } from '../rich-text/schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Rich text, drawn.
|
|
4
|
+
*
|
|
5
|
+
* The reading half of the pair: `RichTextEditor` writes the value, this
|
|
6
|
+
* draws it, and `richTextSchema` is what they both agree on. A server
|
|
7
|
+
* component — no hooks, no handlers — so a page that only shows prose never
|
|
8
|
+
* loads an editor.
|
|
9
|
+
*
|
|
10
|
+
* **No HTML string anywhere in this file**, which is the whole security
|
|
11
|
+
* argument and the difference from `Markdown`. Nothing is parsed and nothing
|
|
12
|
+
* is injected, so there is no sanitiser whose configuration could be wrong.
|
|
13
|
+
* A value that has been through `richTextSchema` holds only the small closed
|
|
14
|
+
* set below; one that has not is still safe here, because a node this has no
|
|
15
|
+
* case for is skipped rather than trusted.
|
|
16
|
+
*
|
|
17
|
+
* **Every outward link gets `rel="noopener noreferrer"`.** The prose is
|
|
18
|
+
* written by somebody, and where a reader came from is not the destination's
|
|
19
|
+
* business.
|
|
20
|
+
*/
|
|
21
|
+
export default function RichText({ value, className, }: {
|
|
22
|
+
value: RichTextValue;
|
|
23
|
+
className?: string;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Rich text, drawn.
|
|
4
|
+
*
|
|
5
|
+
* The reading half of the pair: `RichTextEditor` writes the value, this
|
|
6
|
+
* draws it, and `richTextSchema` is what they both agree on. A server
|
|
7
|
+
* component — no hooks, no handlers — so a page that only shows prose never
|
|
8
|
+
* loads an editor.
|
|
9
|
+
*
|
|
10
|
+
* **No HTML string anywhere in this file**, which is the whole security
|
|
11
|
+
* argument and the difference from `Markdown`. Nothing is parsed and nothing
|
|
12
|
+
* is injected, so there is no sanitiser whose configuration could be wrong.
|
|
13
|
+
* A value that has been through `richTextSchema` holds only the small closed
|
|
14
|
+
* set below; one that has not is still safe here, because a node this has no
|
|
15
|
+
* case for is skipped rather than trusted.
|
|
16
|
+
*
|
|
17
|
+
* **Every outward link gets `rel="noopener noreferrer"`.** The prose is
|
|
18
|
+
* written by somebody, and where a reader came from is not the destination's
|
|
19
|
+
* business.
|
|
20
|
+
*/
|
|
21
|
+
export default function RichText({ value, className, }) {
|
|
22
|
+
return (_jsx("div", { className: className ? `rich-text ${className}` : 'rich-text', children: (value.content ?? []).map((node, index) => renderBlock(node, index)) }));
|
|
23
|
+
}
|
|
24
|
+
function renderBlock(node, key) {
|
|
25
|
+
switch (node.type) {
|
|
26
|
+
case 'paragraph':
|
|
27
|
+
return _jsx("p", { children: renderInline(node.content) }, key);
|
|
28
|
+
case 'heading':
|
|
29
|
+
return node.attrs.level === 2 ? (_jsx("h2", { children: renderInline(node.content) }, key)) : (_jsx("h3", { children: renderInline(node.content) }, key));
|
|
30
|
+
case 'bulletList':
|
|
31
|
+
return _jsx("ul", { children: (node.content ?? []).map((item, i) => renderItem(item, i)) }, key);
|
|
32
|
+
case 'orderedList':
|
|
33
|
+
return (_jsx("ol", { start: node.attrs?.start, children: (node.content ?? []).map((item, i) => renderItem(item, i)) }, key));
|
|
34
|
+
default:
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function renderItem(item, key) {
|
|
39
|
+
return (_jsx("li", { children: item.content.map((paragraph, i) => (
|
|
40
|
+
// The index is the right key here and in `renderInline`: this draws a
|
|
41
|
+
// finished value, the list is never reordered, inserted into or
|
|
42
|
+
// filtered, and the text is not unique — two paragraphs both saying
|
|
43
|
+
// "Yes" are two paragraphs.
|
|
44
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a finished value, never reconciled
|
|
45
|
+
_jsx("p", { children: renderInline(paragraph.content) }, i))) }, key));
|
|
46
|
+
}
|
|
47
|
+
function renderInline(content) {
|
|
48
|
+
if (!content || content.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
return content.map((node, index) => {
|
|
51
|
+
let element = node.text;
|
|
52
|
+
// Applied innermost first, so the order the marks arrive in cannot change
|
|
53
|
+
// the nesting: the link is always outermost, which is what a reader
|
|
54
|
+
// clicks and what a screen reader announces.
|
|
55
|
+
for (const mark of node.marks ?? []) {
|
|
56
|
+
if (mark.type === 'bold')
|
|
57
|
+
element = _jsx("strong", { children: element });
|
|
58
|
+
if (mark.type === 'italic')
|
|
59
|
+
element = _jsx("em", { children: element });
|
|
60
|
+
}
|
|
61
|
+
const link = (node.marks ?? []).find((mark) => mark.type === 'link');
|
|
62
|
+
if (link && link.type === 'link') {
|
|
63
|
+
const external = /^https?:\/\//i.test(link.attrs.href);
|
|
64
|
+
element = (_jsx("a", { href: link.attrs.href, ...(external ? { rel: 'noopener noreferrer' } : {}), children: element }));
|
|
65
|
+
}
|
|
66
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a finished value, never reconciled
|
|
67
|
+
return _jsx("span", { children: element }, index);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RichTextValue } from '../rich-text/schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Writing prose, with a toolbar that offers six things.
|
|
4
|
+
*
|
|
5
|
+
* **The restriction is the component.** TipTap is ProseMirror, which will
|
|
6
|
+
* model tables, colours, fonts, code blocks and arbitrary nesting given the
|
|
7
|
+
* chance. Every one of those is switched off below. What is left — two
|
|
8
|
+
* heading levels, bold, italic, a link and two kinds of list — is exactly
|
|
9
|
+
* what `richTextSchema` admits and exactly what `RichText` can draw, so the
|
|
10
|
+
* three cannot disagree about what a document may contain. A consumer that
|
|
11
|
+
* wants a seventh thing adds it in all three places, on purpose, rather than
|
|
12
|
+
* discovering that a paste brought one in.
|
|
13
|
+
*
|
|
14
|
+
* **`immediatelyRender: false`** because this will be rendered on a server
|
|
15
|
+
* first: without it TipTap builds a document during SSR and React finds a
|
|
16
|
+
* different one on hydration.
|
|
17
|
+
*
|
|
18
|
+
* **The value is TipTap's JSON**, handed back on every change. The component
|
|
19
|
+
* holds no copy of it: the consumer owns the document, which is what lets an
|
|
20
|
+
* editor sit inside a larger form that saves everything at once.
|
|
21
|
+
*
|
|
22
|
+
* TipTap is an optional peer dependency. Import this entry and you need it;
|
|
23
|
+
* import the package's front door and you do not.
|
|
24
|
+
*/
|
|
25
|
+
export default function RichTextEditor({ value, onChange, label, placeholder, className, }: {
|
|
26
|
+
value: RichTextValue;
|
|
27
|
+
onChange: (value: RichTextValue) => void;
|
|
28
|
+
/** What this field is, for a screen reader and for the toolbar's own name. */
|
|
29
|
+
label: string;
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module calls hooks and attaches handlers. It is also
|
|
4
|
+
the only component in the package that loads an editor, which is why it
|
|
5
|
+
ships from its own entry (`@wtfalch/design/editor`) rather than the front
|
|
6
|
+
door: a site that renders prose should not download one to do it. */
|
|
7
|
+
import Link from '@tiptap/extension-link';
|
|
8
|
+
import { Placeholder } from '@tiptap/extensions';
|
|
9
|
+
import { EditorContent, useEditor } from '@tiptap/react';
|
|
10
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
11
|
+
import { useCallback } from 'react';
|
|
12
|
+
import Button from './Button.js';
|
|
13
|
+
/**
|
|
14
|
+
* Writing prose, with a toolbar that offers six things.
|
|
15
|
+
*
|
|
16
|
+
* **The restriction is the component.** TipTap is ProseMirror, which will
|
|
17
|
+
* model tables, colours, fonts, code blocks and arbitrary nesting given the
|
|
18
|
+
* chance. Every one of those is switched off below. What is left — two
|
|
19
|
+
* heading levels, bold, italic, a link and two kinds of list — is exactly
|
|
20
|
+
* what `richTextSchema` admits and exactly what `RichText` can draw, so the
|
|
21
|
+
* three cannot disagree about what a document may contain. A consumer that
|
|
22
|
+
* wants a seventh thing adds it in all three places, on purpose, rather than
|
|
23
|
+
* discovering that a paste brought one in.
|
|
24
|
+
*
|
|
25
|
+
* **`immediatelyRender: false`** because this will be rendered on a server
|
|
26
|
+
* first: without it TipTap builds a document during SSR and React finds a
|
|
27
|
+
* different one on hydration.
|
|
28
|
+
*
|
|
29
|
+
* **The value is TipTap's JSON**, handed back on every change. The component
|
|
30
|
+
* holds no copy of it: the consumer owns the document, which is what lets an
|
|
31
|
+
* editor sit inside a larger form that saves everything at once.
|
|
32
|
+
*
|
|
33
|
+
* TipTap is an optional peer dependency. Import this entry and you need it;
|
|
34
|
+
* import the package's front door and you do not.
|
|
35
|
+
*/
|
|
36
|
+
export default function RichTextEditor({ value, onChange, label, placeholder, className, }) {
|
|
37
|
+
const editor = useEditor({
|
|
38
|
+
extensions: [
|
|
39
|
+
StarterKit.configure({
|
|
40
|
+
// Left on: paragraph, text, bold, italic, the two lists, listItem,
|
|
41
|
+
// history, and the two cursors that make dragging sane.
|
|
42
|
+
heading: { levels: [2, 3] },
|
|
43
|
+
// Off, each one a decision rather than an oversight. A code block and
|
|
44
|
+
// a quote are shapes a block-based consumer expresses as their own
|
|
45
|
+
// blocks; a horizontal rule is a spacer, and spacing belongs to the
|
|
46
|
+
// theme; strike and inline code are marks nobody asked for, and every
|
|
47
|
+
// one of them is another thing two documents can disagree about.
|
|
48
|
+
codeBlock: false,
|
|
49
|
+
blockquote: false,
|
|
50
|
+
horizontalRule: false,
|
|
51
|
+
strike: false,
|
|
52
|
+
code: false,
|
|
53
|
+
// Configured separately below, so its defaults — which allow any
|
|
54
|
+
// scheme — never apply.
|
|
55
|
+
link: false,
|
|
56
|
+
}),
|
|
57
|
+
// What an empty editor says. TipTap's own extension rather than a CSS
|
|
58
|
+
// rule of ours: `attr()` reads the attribute of the element the
|
|
59
|
+
// pseudo-element belongs to, so a rule on the paragraph cannot reach a
|
|
60
|
+
// `data-placeholder` on the box, and ProseMirror's empty paragraph
|
|
61
|
+
// holds a trailing `<br>` so `:empty` never matches it either. Both of
|
|
62
|
+
// those were tried, and the picture of the second is why this is here.
|
|
63
|
+
Placeholder.configure({ placeholder: placeholder ?? '' }),
|
|
64
|
+
Link.configure({
|
|
65
|
+
openOnClick: false,
|
|
66
|
+
autolink: false,
|
|
67
|
+
// The same three `richTextSchema` admits. This is the first of two
|
|
68
|
+
// gates and the schema is the one that counts.
|
|
69
|
+
protocols: ['http', 'https', 'mailto'],
|
|
70
|
+
HTMLAttributes: { rel: 'noopener noreferrer' },
|
|
71
|
+
}),
|
|
72
|
+
],
|
|
73
|
+
content: value,
|
|
74
|
+
immediatelyRender: false,
|
|
75
|
+
editorProps: {
|
|
76
|
+
attributes: {
|
|
77
|
+
// `rich-text` styles the elements ProseMirror builds; the rest is
|
|
78
|
+
// this surface's own box, which is ours to draw. Two of the largest
|
|
79
|
+
// spacing step is about six lines — enough to write a paragraph in
|
|
80
|
+
// without the box growing under the cursor on the first one.
|
|
81
|
+
class: 'rich-text p-3 min-h-[calc(var(--space-15)*2)] outline-none',
|
|
82
|
+
// A contenteditable div is a `generic` to an accessibility tree, and
|
|
83
|
+
// `aria-label` is prohibited on a generic — axe said so on the first
|
|
84
|
+
// run, which is what the suite is for. `textbox` with
|
|
85
|
+
// `aria-multiline` is what a rich text area is, and it is what makes
|
|
86
|
+
// the label legal and announced.
|
|
87
|
+
role: 'textbox',
|
|
88
|
+
'aria-multiline': 'true',
|
|
89
|
+
'aria-label': label,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
|
93
|
+
});
|
|
94
|
+
const setLink = useCallback(() => {
|
|
95
|
+
if (!editor)
|
|
96
|
+
return;
|
|
97
|
+
const current = editor.getAttributes('link').href;
|
|
98
|
+
// A prompt rather than a dialog: it is one field, it is modal either way,
|
|
99
|
+
// and a dialog here would be the first piece of state this component has
|
|
100
|
+
// to own. Worth replacing the day a link needs a second field.
|
|
101
|
+
const href = window.prompt('Link to', current ?? 'https://');
|
|
102
|
+
if (href === null)
|
|
103
|
+
return;
|
|
104
|
+
if (href.trim() === '') {
|
|
105
|
+
editor.chain().focus().unsetLink().run();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
|
|
109
|
+
}, [editor]);
|
|
110
|
+
/* The box.
|
|
111
|
+
|
|
112
|
+
`rounded-(--radius)`, not `rounded`: Tailwind v4 emits a literal
|
|
113
|
+
`0.25rem` for the bare utility and never reads `--radius-DEFAULT`, so
|
|
114
|
+
`rounded` is an unthemeable 4px corner where the vocabulary says 6. The
|
|
115
|
+
screenshots caught it as four moved corners and nothing else. Nothing
|
|
116
|
+
else in the package uses the bare form.
|
|
117
|
+
|
|
118
|
+
`shadow`, not `outline`: `--focus-ring` is a box-shadow value
|
|
119
|
+
(`0 0 0 2px var(--accent)`), so `outline: var(--focus-ring)` is invalid
|
|
120
|
+
and drops the whole declaration silently — which this had, and which no
|
|
121
|
+
screenshot would ever have shown because a baseline is never focused.
|
|
122
|
+
`pagination.css` records the same bug. The ring belongs to the box
|
|
123
|
+
rather than to the contenteditable, so it does not draw a second,
|
|
124
|
+
thinner rectangle inside the border. */
|
|
125
|
+
const box = 'grid overflow-hidden rounded-(--radius) border border-border-strong surface-control focus-within:shadow-(--focus-ring)';
|
|
126
|
+
if (!editor) {
|
|
127
|
+
return _jsx("div", { className: className ? `${box} ${className}` : box, "aria-busy": "true" });
|
|
128
|
+
}
|
|
129
|
+
const controls = [
|
|
130
|
+
{
|
|
131
|
+
label: 'Heading',
|
|
132
|
+
active: editor.isActive('heading', { level: 2 }),
|
|
133
|
+
run: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
label: 'Subheading',
|
|
137
|
+
active: editor.isActive('heading', { level: 3 }),
|
|
138
|
+
run: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
label: 'Bold',
|
|
142
|
+
active: editor.isActive('bold'),
|
|
143
|
+
run: () => editor.chain().focus().toggleBold().run(),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
label: 'Italic',
|
|
147
|
+
active: editor.isActive('italic'),
|
|
148
|
+
run: () => editor.chain().focus().toggleItalic().run(),
|
|
149
|
+
},
|
|
150
|
+
{ label: 'Link', active: editor.isActive('link'), run: setLink },
|
|
151
|
+
{
|
|
152
|
+
label: 'Bullets',
|
|
153
|
+
active: editor.isActive('bulletList'),
|
|
154
|
+
run: () => editor.chain().focus().toggleBulletList().run(),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
label: 'Numbers',
|
|
158
|
+
active: editor.isActive('orderedList'),
|
|
159
|
+
run: () => editor.chain().focus().toggleOrderedList().run(),
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
return (_jsxs("div", { className: className ? `${box} ${className}` : box, children: [_jsx("div", { className: "flex flex-wrap gap-1 border-b border-border p-1 surface-panel", role: "toolbar", "aria-label": `${label} formatting`, children: controls.map((control) => (_jsx(Button, { type: "button", size: "sm", kind: control.active ? 'primary' : 'ghost', "aria-pressed": control.active, onPress: control.run, children: control.label }, control.label))) }), _jsx(EditorContent, { editor: editor })] }));
|
|
163
|
+
}
|
package/dist/editor.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor entry: `@wtfalch/design/editor`.
|
|
3
|
+
*
|
|
4
|
+
* Its own entry because it is the one component in the package that loads an
|
|
5
|
+
* editor — TipTap, which is ProseMirror — and a site that only *renders*
|
|
6
|
+
* prose should not download one to do it. `RichText` and the value helpers
|
|
7
|
+
* ship from the package's front door for exactly that reason; the schema
|
|
8
|
+
* ships from `@wtfalch/design/rich-text`, because it needs zod.
|
|
9
|
+
*
|
|
10
|
+
* TipTap and zod are optional peer dependencies. Import this and you install
|
|
11
|
+
* them; import anything else in the package and you do not.
|
|
12
|
+
*/
|
|
13
|
+
export { default as RichTextEditor } from './components/RichTextEditor.js';
|
|
14
|
+
/** Re-exported, so writing prose needs one import rather than three. */
|
|
15
|
+
export { type BlockNode, type RichTextMark, type RichTextValue, blockNodeSchema, richTextMarkSchema, richTextSchema, } from './rich-text/schema.js';
|
|
16
|
+
export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
|
package/dist/editor.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor entry: `@wtfalch/design/editor`.
|
|
3
|
+
*
|
|
4
|
+
* Its own entry because it is the one component in the package that loads an
|
|
5
|
+
* editor — TipTap, which is ProseMirror — and a site that only *renders*
|
|
6
|
+
* prose should not download one to do it. `RichText` and the value helpers
|
|
7
|
+
* ship from the package's front door for exactly that reason; the schema
|
|
8
|
+
* ships from `@wtfalch/design/rich-text`, because it needs zod.
|
|
9
|
+
*
|
|
10
|
+
* TipTap and zod are optional peer dependencies. Import this and you install
|
|
11
|
+
* them; import anything else in the package and you do not.
|
|
12
|
+
*/
|
|
13
|
+
export { default as RichTextEditor } from './components/RichTextEditor.js';
|
|
14
|
+
/** Re-exported, so writing prose needs one import rather than three. */
|
|
15
|
+
export { blockNodeSchema, richTextMarkSchema, richTextSchema, } from './rich-text/schema.js';
|
|
16
|
+
export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -55,6 +55,14 @@ export type { Props as InputProps } from './components/Input.js';
|
|
|
55
55
|
* of, now one component a consumer can reach. */
|
|
56
56
|
export { default as Kbd } from './components/Kbd.js';
|
|
57
57
|
export { default as Markdown } from './components/Markdown.js';
|
|
58
|
+
/** Prose written with `RichTextEditor` (`@wtfalch/design/editor`), drawn.
|
|
59
|
+
* Here rather than in that entry because reading prose should not download
|
|
60
|
+
* an editor; the schema the two agree on ships with it. */
|
|
61
|
+
export { default as RichText } from './components/RichText.js';
|
|
62
|
+
/** The value's shape and the three helpers that need no validator. The schema
|
|
63
|
+
* itself is `@wtfalch/design/rich-text`, because zod is an optional peer. */
|
|
64
|
+
export type { BlockNode, RichTextMark, RichTextValue } from './rich-text/schema.js';
|
|
65
|
+
export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
|
|
58
66
|
export { default as Menu } from './components/Menu.js';
|
|
59
67
|
export type { Item as MenuItem, Section as MenuSection } from './components/Menu.js';
|
|
60
68
|
export { default as Modal } from './components/Modal.js';
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,11 @@ export { default as Input } from './components/Input.js';
|
|
|
49
49
|
* of, now one component a consumer can reach. */
|
|
50
50
|
export { default as Kbd } from './components/Kbd.js';
|
|
51
51
|
export { default as Markdown } from './components/Markdown.js';
|
|
52
|
+
/** Prose written with `RichTextEditor` (`@wtfalch/design/editor`), drawn.
|
|
53
|
+
* Here rather than in that entry because reading prose should not download
|
|
54
|
+
* an editor; the schema the two agree on ships with it. */
|
|
55
|
+
export { default as RichText } from './components/RichText.js';
|
|
56
|
+
export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
|
|
52
57
|
export { default as Menu } from './components/Menu.js';
|
|
53
58
|
export { default as Modal } from './components/Modal.js';
|
|
54
59
|
/** Moving through a list that does not fit, counted in items rather than
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* What a rich-text value is, and the only shape `RichTextEditor` produces or
|
|
4
|
+
* `RichText` draws.
|
|
5
|
+
*
|
|
6
|
+
* **A restricted document, on purpose.** The editor is TipTap, which is
|
|
7
|
+
* ProseMirror, which will model tables, colours, fonts and arbitrary nesting
|
|
8
|
+
* given the chance. A system where every writer can reach all of that is one
|
|
9
|
+
* where two pages share nothing but a logo. What is left here is
|
|
10
|
+
* paragraphs, two heading levels, two kinds of list, bold, italic and links
|
|
11
|
+
* — the set a piece of prose actually needs.
|
|
12
|
+
*
|
|
13
|
+
* **The schema is the contract between three things**: what the toolbar can
|
|
14
|
+
* produce, what a consumer should store, and what the view can draw. A
|
|
15
|
+
* consumer validates with this on the way into its database, so a crafted
|
|
16
|
+
* request can no more insert a table than the editor can, and a node the
|
|
17
|
+
* view has no case for cannot arrive.
|
|
18
|
+
*
|
|
19
|
+
* Zod is an OPTIONAL peer dependency, and this file is the only one that
|
|
20
|
+
* reaches it — which is why it ships from `@wtfalch/design/rich-text` rather
|
|
21
|
+
* than the front door. A consumer that renders prose without validating it
|
|
22
|
+
* should not have to install a validator to load the package. The types and
|
|
23
|
+
* the pure helpers live in `./value`, which imports from here with `import
|
|
24
|
+
* type` and so compiles to nothing.
|
|
25
|
+
*/
|
|
26
|
+
/** The marks a run of text may carry. `link` is the only one with a value, and its href is checked. */
|
|
27
|
+
export declare const richTextMarkSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
28
|
+
type: z.ZodLiteral<"bold">;
|
|
29
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
30
|
+
type: z.ZodLiteral<"italic">;
|
|
31
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
32
|
+
type: z.ZodLiteral<"link">;
|
|
33
|
+
attrs: z.ZodObject<{
|
|
34
|
+
href: z.ZodString;
|
|
35
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
36
|
+
}, z.core.$strip>;
|
|
37
|
+
}, z.core.$strip>], "type">;
|
|
38
|
+
/** Named `RichTextMark` rather than `Mark`, which this package already uses for a product's brand mark. */
|
|
39
|
+
export type RichTextMark = z.infer<typeof richTextMarkSchema>;
|
|
40
|
+
export declare const blockNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
41
|
+
type: z.ZodLiteral<"paragraph">;
|
|
42
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
43
|
+
type: z.ZodLiteral<"text">;
|
|
44
|
+
text: z.ZodString;
|
|
45
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
46
|
+
type: z.ZodLiteral<"bold">;
|
|
47
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
48
|
+
type: z.ZodLiteral<"italic">;
|
|
49
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
50
|
+
type: z.ZodLiteral<"link">;
|
|
51
|
+
attrs: z.ZodObject<{
|
|
52
|
+
href: z.ZodString;
|
|
53
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
54
|
+
}, z.core.$strip>;
|
|
55
|
+
}, z.core.$strip>], "type">>>;
|
|
56
|
+
}, z.core.$strip>>>;
|
|
57
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
58
|
+
type: z.ZodLiteral<"heading">;
|
|
59
|
+
attrs: z.ZodObject<{
|
|
60
|
+
level: z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>]>;
|
|
61
|
+
}, z.core.$strip>;
|
|
62
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
63
|
+
type: z.ZodLiteral<"text">;
|
|
64
|
+
text: z.ZodString;
|
|
65
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
66
|
+
type: z.ZodLiteral<"bold">;
|
|
67
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
68
|
+
type: z.ZodLiteral<"italic">;
|
|
69
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
70
|
+
type: z.ZodLiteral<"link">;
|
|
71
|
+
attrs: z.ZodObject<{
|
|
72
|
+
href: z.ZodString;
|
|
73
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
74
|
+
}, z.core.$strip>;
|
|
75
|
+
}, z.core.$strip>], "type">>>;
|
|
76
|
+
}, z.core.$strip>>>;
|
|
77
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
78
|
+
type: z.ZodLiteral<"bulletList">;
|
|
79
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
80
|
+
type: z.ZodLiteral<"listItem">;
|
|
81
|
+
content: z.ZodArray<z.ZodObject<{
|
|
82
|
+
type: z.ZodLiteral<"paragraph">;
|
|
83
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
84
|
+
type: z.ZodLiteral<"text">;
|
|
85
|
+
text: z.ZodString;
|
|
86
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
87
|
+
type: z.ZodLiteral<"bold">;
|
|
88
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
89
|
+
type: z.ZodLiteral<"italic">;
|
|
90
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
91
|
+
type: z.ZodLiteral<"link">;
|
|
92
|
+
attrs: z.ZodObject<{
|
|
93
|
+
href: z.ZodString;
|
|
94
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
95
|
+
}, z.core.$strip>;
|
|
96
|
+
}, z.core.$strip>], "type">>>;
|
|
97
|
+
}, z.core.$strip>>>;
|
|
98
|
+
}, z.core.$strip>>;
|
|
99
|
+
}, z.core.$strip>>>;
|
|
100
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
101
|
+
type: z.ZodLiteral<"orderedList">;
|
|
102
|
+
attrs: z.ZodOptional<z.ZodObject<{
|
|
103
|
+
start: z.ZodNumber;
|
|
104
|
+
}, z.core.$strip>>;
|
|
105
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
106
|
+
type: z.ZodLiteral<"listItem">;
|
|
107
|
+
content: z.ZodArray<z.ZodObject<{
|
|
108
|
+
type: z.ZodLiteral<"paragraph">;
|
|
109
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
110
|
+
type: z.ZodLiteral<"text">;
|
|
111
|
+
text: z.ZodString;
|
|
112
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
113
|
+
type: z.ZodLiteral<"bold">;
|
|
114
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
115
|
+
type: z.ZodLiteral<"italic">;
|
|
116
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
117
|
+
type: z.ZodLiteral<"link">;
|
|
118
|
+
attrs: z.ZodObject<{
|
|
119
|
+
href: z.ZodString;
|
|
120
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
121
|
+
}, z.core.$strip>;
|
|
122
|
+
}, z.core.$strip>], "type">>>;
|
|
123
|
+
}, z.core.$strip>>>;
|
|
124
|
+
}, z.core.$strip>>;
|
|
125
|
+
}, z.core.$strip>>>;
|
|
126
|
+
}, z.core.$strip>], "type">;
|
|
127
|
+
export type BlockNode = z.infer<typeof blockNodeSchema>;
|
|
128
|
+
/** A whole value: what TipTap calls the document. */
|
|
129
|
+
export declare const richTextSchema: z.ZodObject<{
|
|
130
|
+
type: z.ZodLiteral<"doc">;
|
|
131
|
+
content: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
132
|
+
type: z.ZodLiteral<"paragraph">;
|
|
133
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
134
|
+
type: z.ZodLiteral<"text">;
|
|
135
|
+
text: z.ZodString;
|
|
136
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
137
|
+
type: z.ZodLiteral<"bold">;
|
|
138
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
139
|
+
type: z.ZodLiteral<"italic">;
|
|
140
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
141
|
+
type: z.ZodLiteral<"link">;
|
|
142
|
+
attrs: z.ZodObject<{
|
|
143
|
+
href: z.ZodString;
|
|
144
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
145
|
+
}, z.core.$strip>;
|
|
146
|
+
}, z.core.$strip>], "type">>>;
|
|
147
|
+
}, z.core.$strip>>>;
|
|
148
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
149
|
+
type: z.ZodLiteral<"heading">;
|
|
150
|
+
attrs: z.ZodObject<{
|
|
151
|
+
level: z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>]>;
|
|
152
|
+
}, z.core.$strip>;
|
|
153
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
154
|
+
type: z.ZodLiteral<"text">;
|
|
155
|
+
text: z.ZodString;
|
|
156
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
157
|
+
type: z.ZodLiteral<"bold">;
|
|
158
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
159
|
+
type: z.ZodLiteral<"italic">;
|
|
160
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
161
|
+
type: z.ZodLiteral<"link">;
|
|
162
|
+
attrs: z.ZodObject<{
|
|
163
|
+
href: z.ZodString;
|
|
164
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
165
|
+
}, z.core.$strip>;
|
|
166
|
+
}, z.core.$strip>], "type">>>;
|
|
167
|
+
}, z.core.$strip>>>;
|
|
168
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
169
|
+
type: z.ZodLiteral<"bulletList">;
|
|
170
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
171
|
+
type: z.ZodLiteral<"listItem">;
|
|
172
|
+
content: z.ZodArray<z.ZodObject<{
|
|
173
|
+
type: z.ZodLiteral<"paragraph">;
|
|
174
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
175
|
+
type: z.ZodLiteral<"text">;
|
|
176
|
+
text: z.ZodString;
|
|
177
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
178
|
+
type: z.ZodLiteral<"bold">;
|
|
179
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
180
|
+
type: z.ZodLiteral<"italic">;
|
|
181
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
182
|
+
type: z.ZodLiteral<"link">;
|
|
183
|
+
attrs: z.ZodObject<{
|
|
184
|
+
href: z.ZodString;
|
|
185
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
186
|
+
}, z.core.$strip>;
|
|
187
|
+
}, z.core.$strip>], "type">>>;
|
|
188
|
+
}, z.core.$strip>>>;
|
|
189
|
+
}, z.core.$strip>>;
|
|
190
|
+
}, z.core.$strip>>>;
|
|
191
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
192
|
+
type: z.ZodLiteral<"orderedList">;
|
|
193
|
+
attrs: z.ZodOptional<z.ZodObject<{
|
|
194
|
+
start: z.ZodNumber;
|
|
195
|
+
}, z.core.$strip>>;
|
|
196
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
197
|
+
type: z.ZodLiteral<"listItem">;
|
|
198
|
+
content: z.ZodArray<z.ZodObject<{
|
|
199
|
+
type: z.ZodLiteral<"paragraph">;
|
|
200
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
201
|
+
type: z.ZodLiteral<"text">;
|
|
202
|
+
text: z.ZodString;
|
|
203
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
204
|
+
type: z.ZodLiteral<"bold">;
|
|
205
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
206
|
+
type: z.ZodLiteral<"italic">;
|
|
207
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
208
|
+
type: z.ZodLiteral<"link">;
|
|
209
|
+
attrs: z.ZodObject<{
|
|
210
|
+
href: z.ZodString;
|
|
211
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
212
|
+
}, z.core.$strip>;
|
|
213
|
+
}, z.core.$strip>], "type">>>;
|
|
214
|
+
}, z.core.$strip>>>;
|
|
215
|
+
}, z.core.$strip>>;
|
|
216
|
+
}, z.core.$strip>>>;
|
|
217
|
+
}, z.core.$strip>], "type">>>;
|
|
218
|
+
}, z.core.$strip>;
|
|
219
|
+
export type RichTextValue = z.infer<typeof richTextSchema>;
|