@riducms/plugin-richtext 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haniel Ubogu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # `@riducms/plugin-richtext`
2
+
3
+ The Svelte/Lexical editor for Ridu's Go rich-text plugin. It exports the admin field registration and
4
+ authoring UI used by generated projects.
5
+
6
+ Add the Go and admin packages together. In an existing project:
7
+
8
+ ```sh
9
+ npm run ridu -- add richtext \
10
+ --go-package github.com/riducms/ridu/plugins/richtext \
11
+ --admin-package @riducms/plugin-richtext
12
+ ```
13
+
14
+ Add `richtext.New()` to `Config.Plugins` and author fields with `richtext.Field`. The generated admin
15
+ registry imports `richTextAdminPlugin`; do not register it again. Generation rejects incompatible Go
16
+ and npm pairing metadata before the editor loads.
17
+
18
+ Run `npm run dev`, verify the editor, then create and review the adapter migration before deployment:
19
+
20
+ ```sh
21
+ npm run ridu -- migrate create --name add-rich-text
22
+ ```
23
+
24
+ See the [Rich text guide](../../website/src/content/docs/rich-text.md) for new- and
25
+ existing-project setup, feature selection, uploads/relationships, portable values, constraints,
26
+ safe Go rendering, migrations, and verification.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "bugs": {
3
+ "url": "https://github.com/riducms/ridu/issues"
4
+ },
5
+ "dependencies": {
6
+ "@hvniel/lexical-svelte": "^0.1.1",
7
+ "@lexical/code": "0.49.0",
8
+ "@lexical/history": "0.49.0",
9
+ "@lexical/link": "0.49.0",
10
+ "@lexical/list": "0.49.0",
11
+ "@lexical/rich-text": "0.49.0",
12
+ "@lexical/selection": "0.49.0",
13
+ "@lexical/utils": "0.49.0",
14
+ "@riducms/plugin": "0.1.0",
15
+ "@riducms/protocol": "0.1.0",
16
+ "@riducms/ui": "0.1.0",
17
+ "@types/prismjs": "^1.26.5",
18
+ "lexical": "0.49.0",
19
+ "prismjs": "^1.30.0",
20
+ "svelte": "^5.56.8"
21
+ },
22
+ "description": "Svelte and Lexical admin integration for Ridu's official rich-text plugin.",
23
+ "devDependencies": {
24
+ "@riducms/build": "0.1.0"
25
+ },
26
+ "exports": {
27
+ ".": "./src/index.ts"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "LICENSE"
32
+ ],
33
+ "homepage": "https://riducms.com",
34
+ "license": "MIT",
35
+ "name": "@riducms/plugin-richtext",
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "provenance": true
39
+ },
40
+ "repository": {
41
+ "directory": "packages/plugin-richtext",
42
+ "type": "git",
43
+ "url": "git+https://github.com/riducms/ridu.git"
44
+ },
45
+ "scripts": {
46
+ "build": "vite build",
47
+ "check": "svelte-check --tsconfig ./tsconfig.json",
48
+ "test": "bun test"
49
+ },
50
+ "type": "module",
51
+ "version": "0.1.0"
52
+ }
@@ -0,0 +1,47 @@
1
+ export type RichTextFeature =
2
+ "links" | "lists" | "code" | "horizontal-rule" | "uploads" | "relationships" | "blocks";
3
+
4
+ export interface RichTextConfig {
5
+ features: readonly RichTextFeature[];
6
+ uploadCollections: readonly string[];
7
+ relationshipCollections: readonly string[];
8
+ }
9
+
10
+ const features = new Set<RichTextFeature>([
11
+ "links",
12
+ "lists",
13
+ "code",
14
+ "horizontal-rule",
15
+ "uploads",
16
+ "relationships",
17
+ "blocks",
18
+ ]);
19
+
20
+ export function parseRichTextConfig(value: unknown): RichTextConfig {
21
+ if (!isRecord(value)) return { features: [], uploadCollections: [], relationshipCollections: [] };
22
+ return {
23
+ features: Array.isArray(value.features) ? value.features.filter(isRichTextFeature) : [],
24
+ uploadCollections: Array.isArray(value.uploadCollections)
25
+ ? value.uploadCollections.filter(
26
+ (collection): collection is string => typeof collection === "string"
27
+ )
28
+ : [],
29
+ relationshipCollections: Array.isArray(value.relationshipCollections)
30
+ ? value.relationshipCollections.filter(
31
+ (collection): collection is string => typeof collection === "string"
32
+ )
33
+ : [],
34
+ };
35
+ }
36
+
37
+ export function hasRichTextFeature(config: RichTextConfig, feature: RichTextFeature): boolean {
38
+ return config.features.includes(feature);
39
+ }
40
+
41
+ function isRichTextFeature(value: unknown): value is RichTextFeature {
42
+ return typeof value === "string" && features.has(value as RichTextFeature);
43
+ }
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
@@ -0,0 +1,8 @@
1
+ import type { FieldAuthoringHost } from "@riducms/plugin";
2
+ import { createContext } from "svelte";
3
+
4
+ const [getRichTextAuthoringHost, setRichTextAuthoringHost] = createContext<
5
+ FieldAuthoringHost | undefined
6
+ >();
7
+
8
+ export { getRichTextAuthoringHost, setRichTextAuthoringHost };
@@ -0,0 +1,20 @@
1
+ export function initialEditorState(value: unknown) {
2
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.root)) return null;
3
+ return JSON.stringify({ root: withElementDefaults(value.root) });
4
+ }
5
+
6
+ function withElementDefaults(node: Record<string, unknown>): Record<string, unknown> {
7
+ if (!Array.isArray(node.children)) return node;
8
+
9
+ return {
10
+ ...node,
11
+ children: node.children.map((child) => (isRecord(child) ? withElementDefaults(child) : child)),
12
+ direction: node.direction ?? null,
13
+ format: node.format ?? "",
14
+ indent: node.indent ?? 0,
15
+ };
16
+ }
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
@@ -0,0 +1,187 @@
1
+ <script lang="ts">
2
+ import type { FieldComponentProps } from "@riducms/plugin";
3
+ import { CodeNode } from "@lexical/code";
4
+ import { HistoryExtension } from "@lexical/history";
5
+ import { LinkNode } from "@lexical/link";
6
+ import { ListExtension } from "@lexical/list";
7
+ import { RichTextExtension } from "@lexical/rich-text";
8
+ import {
9
+ ContentEditable,
10
+ HorizontalRuleNode,
11
+ HorizontalRulePlugin,
12
+ LexicalExtensionComposer,
13
+ LinkPlugin,
14
+ OnChangePlugin,
15
+ } from "@hvniel/lexical-svelte";
16
+ import { defineExtension, type EditorState } from "lexical";
17
+ import { onDestroy } from "svelte";
18
+ import { FieldFrame } from "@riducms/ui";
19
+
20
+ import "@plugin-richtext/styles/integration.css";
21
+ import RichTextBlockToolbar from "@plugin-richtext/menu/rich-text-block-toolbar.svelte";
22
+ import RichTextFooter from "@plugin-richtext/field/rich-text-footer.svelte";
23
+ import RichTextFloatingToolbar from "@plugin-richtext/toolbar/rich-text-floating-toolbar.svelte";
24
+ import RichTextSlashMenu from "@plugin-richtext/menu/rich-text-slash-menu.svelte";
25
+ import { parseRichTextConfig, hasRichTextFeature } from "@plugin-richtext/field/rich-text-config";
26
+ import { setRichTextAuthoringHost } from "@plugin-richtext/field/rich-text-context.svelte";
27
+ import { initialEditorState } from "@plugin-richtext/field/rich-text-document";
28
+ import { UploadNode } from "@plugin-richtext/upload/rich-text-upload-node";
29
+ import RichTextUploadPlugin from "@plugin-richtext/upload/rich-text-upload-plugin.svelte";
30
+ import { RelationshipNode } from "@plugin-richtext/relationship/rich-text-relationship-node";
31
+ import RichTextRelationshipPlugin from "@plugin-richtext/relationship/rich-text-relationship-plugin.svelte";
32
+
33
+ let { field, form, authoring, i18n }: FieldComponentProps = $props();
34
+ const issues = $derived(form.issuesFor(field.path));
35
+ const messageID = $derived(`${field.id}-message`);
36
+ const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
37
+ const placeholderText = $derived(i18n.t("plugin.richtext:editor.placeholder"));
38
+ let canvasElement = $state<HTMLElement | null>(null);
39
+ // Field identity and plugin config are fixed for this mounted component.
40
+ // svelte-ignore state_referenced_locally
41
+ const config = parseRichTextConfig(field.plugin?.config);
42
+ // The host is stable for this mounted field and decorator descendants inherit it through portals.
43
+ // svelte-ignore state_referenced_locally
44
+ setRichTextAuthoringHost(authoring);
45
+
46
+ const theme = {
47
+ heading: {
48
+ h1: "ridu-richtext-heading ridu-richtext-h1 mt-[2.1rem] mb-[0.85rem] font-serif text-[2rem] leading-[1.12] tracking-[-0.015em] text-foreground font-normal text-balance max-[34rem]:text-[1.75rem]",
49
+ h2: "ridu-richtext-heading ridu-richtext-h2 mt-[2.1rem] mb-[0.85rem] font-serif text-[1.75rem] leading-[1.12] text-foreground font-normal text-balance",
50
+ h3: "ridu-richtext-heading ridu-richtext-h3 mt-[2.1rem] mb-[0.85rem] font-sans text-lg leading-[1.35] text-foreground font-semibold text-balance",
51
+ },
52
+ code: "ridu-richtext-code-block my-6 block overflow-x-auto rounded-[3px] border border-control-border bg-control px-[1.1rem] py-4 font-mono text-[12.5px] leading-[1.7] text-foreground-strong [tab-size:2]",
53
+ hr: "ridu-richtext-rule my-8 block cursor-pointer border-0 border-t border-border py-[0.65rem]",
54
+ hrSelected: "is-selected",
55
+ link: "ridu-richtext-link cursor-pointer text-brand-primary-soft underline decoration-primary/40 decoration-1 underline-offset-[0.18em] hover:text-primary-hover hover:decoration-current",
56
+ list: {
57
+ checklist:
58
+ "ridu-richtext-checklist mt-1 mb-[1.35rem] list-none ps-0 text-[15.5px] leading-7 text-foreground-body",
59
+ listitemChecked: "ridu-richtext-list-item-checked line-through opacity-70",
60
+ listitemUnchecked: "ridu-richtext-list-item-unchecked",
61
+ listitem: "ridu-richtext-list-item ps-1",
62
+ nested: { listitem: "ridu-richtext-list-item-nested" },
63
+ ol: "ridu-richtext-list-ordered mt-1 mb-[1.35rem] list-decimal ps-[1.55rem] text-[15.5px] leading-7 text-foreground-body",
64
+ ul: "ridu-richtext-list-unordered mt-1 mb-[1.35rem] list-disc ps-[1.55rem] text-[15.5px] leading-7 text-foreground-body",
65
+ },
66
+ paragraph:
67
+ "ridu-richtext-paragraph relative mb-[0.9rem] text-[15.5px] leading-7 text-foreground-body text-pretty last:mb-0",
68
+ quote:
69
+ "ridu-richtext-quote my-[1.85rem] border-s-2 border-primary/55 py-[0.1rem] ps-5 font-serif text-[19px] italic leading-6 text-foreground-strong text-pretty",
70
+ text: {
71
+ bold: "ridu-richtext-bold font-semibold",
72
+ code: "ridu-richtext-inline-code rounded-[3px] border border-control-border bg-control px-[0.32em] py-[0.12em] font-mono text-[0.86em] text-foreground-strong",
73
+ italic: "ridu-richtext-italic italic",
74
+ subscript: "ridu-richtext-subscript",
75
+ superscript: "ridu-richtext-superscript",
76
+ strikethrough: "ridu-richtext-strikethrough line-through",
77
+ underline: "ridu-richtext-underline underline underline-offset-[0.15em]",
78
+ },
79
+ upload: "ridu-richtext-upload m-0",
80
+ };
81
+
82
+ // Field identity is fixed for this mounted plugin component.
83
+ // svelte-ignore state_referenced_locally
84
+ const unregisterField = form.register(field.path);
85
+ onDestroy(unregisterField);
86
+
87
+ // The extension tree is identity-bearing and belongs to this field instance.
88
+ // svelte-ignore state_referenced_locally
89
+ const extension = defineExtension({
90
+ $initialEditorState: initialEditorState(form.get(field.path)),
91
+ dependencies: [
92
+ RichTextExtension,
93
+ HistoryExtension,
94
+ ...(hasRichTextFeature(config, "lists") ? [ListExtension] : []),
95
+ ],
96
+ name: "@riducms/plugin-richtext/editor",
97
+ namespace: `ridu-${field.id}`,
98
+ nodes: [
99
+ ...(hasRichTextFeature(config, "links") ? [LinkNode] : []),
100
+ ...(hasRichTextFeature(config, "code") ? [CodeNode] : []),
101
+ ...(hasRichTextFeature(config, "horizontal-rule") ? [HorizontalRuleNode] : []),
102
+ ...(hasRichTextFeature(config, "uploads") ? [UploadNode] : []),
103
+ ...(hasRichTextFeature(config, "relationships") ? [RelationshipNode] : []),
104
+ ],
105
+ editable: !field.admin.readOnly,
106
+ theme,
107
+ onError: (error) => {
108
+ throw error;
109
+ },
110
+ });
111
+
112
+ function changed(editorState: EditorState) {
113
+ form.set(field.path, { version: 1, root: editorState.toJSON().root });
114
+ }
115
+ </script>
116
+
117
+ <div data-field-path={field.path}>
118
+ <FieldFrame
119
+ controlID={field.id}
120
+ label={field.admin.label}
121
+ required={field.required}
122
+ readOnly={field.admin.readOnly}
123
+ description={field.admin.description}
124
+ errors={issues.map((issue) => issue.message)}
125
+ class="min-w-0"
126
+ >
127
+ <div
128
+ class={[
129
+ "ridu-richtext-editor min-w-0 border-b border-transparent pb-[0.65rem] transition-colors duration-150",
130
+ issues.length > 0 && "border-b-destructive/22",
131
+ field.admin.readOnly && "pt-[1.6rem]",
132
+ issues.length > 0 && "has-error",
133
+ field.admin.readOnly && "is-read-only",
134
+ ]}
135
+ aria-invalid={issues.length > 0}
136
+ >
137
+ <LexicalExtensionComposer {extension} contentEditable={null}>
138
+ <div class="relative" bind:this={canvasElement}>
139
+ <ContentEditable
140
+ id={field.id}
141
+ class={[
142
+ "ridu-richtext-content relative z-1 min-h-44 pt-[1.35rem] pe-0 pb-3 ps-9 text-foreground-body caret-primary outline-none focus-visible:outline-none max-[34rem]:min-h-36 max-[34rem]:pt-4 max-[34rem]:ps-8",
143
+ field.admin.readOnly
144
+ ? "min-h-auto pt-0 ps-0 max-[34rem]:min-h-auto max-[34rem]:pt-0 max-[34rem]:ps-0"
145
+ : "border-s border-border transition-colors duration-150",
146
+ issues.length > 0 && !field.admin.readOnly && "border-s-destructive/72",
147
+ ]}
148
+ ariaLabel={field.admin.label}
149
+ ariaDescribedBy={hasMessage ? messageID : undefined}
150
+ ariaInvalid={issues.length > 0}
151
+ ariaRequired={field.required}
152
+ aria-keyshortcuts="Alt+Shift+ArrowUp Alt+Shift+ArrowDown"
153
+ aria-placeholder={placeholderText}
154
+ >
155
+ {#snippet placeholder(editable)}
156
+ <span
157
+ class={[
158
+ "pointer-events-none absolute top-[1.35rem] start-9 z-0 text-[15.5px] leading-7 text-foreground-placeholder max-[34rem]:top-4 max-[34rem]:start-8",
159
+ !editable && "top-0 start-0 max-[34rem]:top-0 max-[34rem]:start-0",
160
+ ]}
161
+ >
162
+ {editable ? placeholderText : i18n.t("plugin.richtext:editor.empty")}
163
+ </span>
164
+ {/snippet}
165
+ </ContentEditable>
166
+ </div>
167
+ {#if hasRichTextFeature(config, "links")}<LinkPlugin />{/if}
168
+ {#if hasRichTextFeature(config, "horizontal-rule")}<HorizontalRulePlugin />{/if}
169
+ {#if !field.admin.readOnly}
170
+ <RichTextSlashMenu {authoring} {config} />
171
+ <RichTextFloatingToolbar />
172
+ {#if canvasElement !== null}
173
+ <RichTextBlockToolbar anchorElement={canvasElement} {authoring} {config} />
174
+ {/if}
175
+ {#if hasRichTextFeature(config, "uploads")}
176
+ <RichTextUploadPlugin {authoring} {config} {field} />
177
+ {/if}
178
+ {#if hasRichTextFeature(config, "relationships")}
179
+ <RichTextRelationshipPlugin {authoring} {config} {field} />
180
+ {/if}
181
+ {/if}
182
+ <OnChangePlugin onChange={changed} ignoreSelectionChange />
183
+ <RichTextFooter />
184
+ </LexicalExtensionComposer>
185
+ </div>
186
+ </FieldFrame>
187
+ </div>
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ import { getAdminI18n } from "@riducms/plugin";
3
+ import { useLexicalComposerContext, useLexicalEditable } from "@hvniel/lexical-svelte";
4
+ import {
5
+ $createParagraphNode,
6
+ $getRoot,
7
+ $getSelection,
8
+ $isParagraphNode,
9
+ $isRangeSelection,
10
+ } from "lexical";
11
+
12
+ const editor = useLexicalComposerContext()[0];
13
+ const isEditable = useLexicalEditable();
14
+ const i18n = getAdminI18n();
15
+
16
+ function focusAtEnd() {
17
+ editor.focus(
18
+ () => {
19
+ editor.update(() => {
20
+ const root = $getRoot();
21
+ const last = root.getLastChild();
22
+ const paragraph =
23
+ $isParagraphNode(last) && last.getTextContentSize() === 0
24
+ ? last
25
+ : $createParagraphNode();
26
+ if (paragraph.getParent() === null) root.append(paragraph);
27
+ paragraph.selectEnd();
28
+ const selection = $getSelection();
29
+ if ($isRangeSelection(selection)) selection.insertText("/");
30
+ });
31
+ },
32
+ { defaultSelection: "rootEnd" }
33
+ );
34
+ }
35
+ </script>
36
+
37
+ {#if isEditable()}
38
+ <button
39
+ class="mt-[0.55rem] inline-flex cursor-text items-center gap-[0.35rem] border-0 bg-transparent py-[0.35rem] font-mono text-[11px] leading-[1.2] text-foreground-sub transition-colors duration-150 hover:text-foreground-muted focus-visible:rounded focus-visible:text-foreground-muted focus-visible:outline-2 focus-visible:outline-ring/60 focus-visible:outline-offset-3"
40
+ type="button"
41
+ onclick={focusAtEnd}
42
+ >
43
+ <span aria-hidden="true">+</span>
44
+ {i18n.t("plugin.richtext:editor.addBlock")}
45
+ <span class="text-foreground-sub max-[34rem]:hidden">
46
+ {i18n.t("plugin.richtext:editor.footerHint")}
47
+ </span>
48
+ </button>
49
+ {/if}
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { ADMIN_PLUGIN_API_VERSION, defineAdminPlugin, defineFieldPlugin } from "@riducms/plugin";
2
+ import Prism from "prismjs";
3
+
4
+ import { richTextMessages } from "@plugin-richtext/messages";
5
+
6
+ const browserGlobals = globalThis as typeof globalThis & { Prism?: typeof Prism };
7
+ browserGlobals.Prism ??= Prism;
8
+
9
+ const { default: RichTextField } = await import("@plugin-richtext/field/rich-text-field.svelte");
10
+
11
+ export const richTextFieldPlugin = defineFieldPlugin({
12
+ type: "plugin",
13
+ key: "richtext",
14
+ component: RichTextField,
15
+ canRender: (field) => field.plugin?.key === "richtext",
16
+ });
17
+
18
+ export const richTextAdminPlugin = defineAdminPlugin({
19
+ apiVersion: ADMIN_PLUGIN_API_VERSION,
20
+ key: "richtext",
21
+ pairingVersion: 1,
22
+ fields: [richTextFieldPlugin],
23
+ messages: richTextMessages,
24
+ });
25
+
26
+ export interface RichTextDocument {
27
+ version: 1;
28
+ root: Record<string, unknown>;
29
+ }
30
+
31
+ export { richTextMessages } from "@plugin-richtext/messages";
32
+ export type { RichTextConfig, RichTextFeature } from "@plugin-richtext/field/rich-text-config";
33
+ export type { SerializedUploadNode } from "@plugin-richtext/upload/rich-text-upload-node";