@mp-lb/mdkit 0.2.5 → 0.3.1

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.
@@ -0,0 +1,88 @@
1
+ import { parseDocument } from "yaml";
2
+ const delimiter = "---";
3
+ const getLineEnd = (markdown, lineStart) => {
4
+ const newlineIndex = markdown.indexOf("\n", lineStart);
5
+ if (newlineIndex === -1) {
6
+ return {
7
+ contentEnd: markdown.length,
8
+ lineEnd: markdown.length,
9
+ newline: "",
10
+ };
11
+ }
12
+ const contentEnd = newlineIndex > lineStart && markdown[newlineIndex - 1] === "\r"
13
+ ? newlineIndex - 1
14
+ : newlineIndex;
15
+ return {
16
+ contentEnd,
17
+ lineEnd: newlineIndex + 1,
18
+ newline: markdown.slice(contentEnd, newlineIndex + 1),
19
+ };
20
+ };
21
+ const getNextBodyStart = (markdown, lineStart) => {
22
+ let bodyStart = lineStart;
23
+ while (bodyStart < markdown.length) {
24
+ const lineEnd = getLineEnd(markdown, bodyStart);
25
+ const line = markdown.slice(bodyStart, lineEnd.contentEnd);
26
+ if (!/^[ \t]*$/.test(line)) {
27
+ break;
28
+ }
29
+ bodyStart = lineEnd.lineEnd;
30
+ }
31
+ return bodyStart;
32
+ };
33
+ export const parseYamlFrontMatter = (yaml) => {
34
+ const document = parseDocument(yaml, { prettyErrors: false });
35
+ if (document.errors.length > 0) {
36
+ throw new Error(document.errors.map((error) => error.message).join("\n"));
37
+ }
38
+ return document.toJSON();
39
+ };
40
+ export const extractYamlFrontMatter = (markdown) => {
41
+ const openingLine = getLineEnd(markdown, 0);
42
+ if (markdown.slice(0, openingLine.contentEnd) !== delimiter) {
43
+ return { body: markdown, errors: [], frontMatter: null };
44
+ }
45
+ let lineStart = openingLine.lineEnd;
46
+ while (lineStart < markdown.length) {
47
+ const lineEnd = getLineEnd(markdown, lineStart);
48
+ const line = markdown.slice(lineStart, lineEnd.contentEnd);
49
+ if (line !== delimiter) {
50
+ lineStart = lineEnd.lineEnd;
51
+ continue;
52
+ }
53
+ const bodyStart = getNextBodyStart(markdown, lineEnd.lineEnd);
54
+ const raw = markdown.slice(0, bodyStart);
55
+ const yaml = markdown.slice(openingLine.lineEnd, lineStart);
56
+ const trailingWhitespace = markdown.slice(lineEnd.lineEnd, bodyStart);
57
+ try {
58
+ const data = parseYamlFrontMatter(yaml);
59
+ return {
60
+ body: markdown.slice(bodyStart),
61
+ errors: [],
62
+ frontMatter: {
63
+ data,
64
+ raw,
65
+ trailingWhitespace,
66
+ yaml,
67
+ },
68
+ };
69
+ }
70
+ catch (error) {
71
+ return {
72
+ body: markdown,
73
+ errors: [error instanceof Error ? error.message : String(error)],
74
+ frontMatter: null,
75
+ };
76
+ }
77
+ }
78
+ return { body: markdown, errors: [], frontMatter: null };
79
+ };
80
+ export const hasYamlFrontMatter = (markdown) => extractYamlFrontMatter(markdown).frontMatter !== null;
81
+ export const removeYamlFrontMatter = (markdown) => extractYamlFrontMatter(markdown).body;
82
+ export const prependYamlFrontMatter = (frontMatter, body) => {
83
+ if (!frontMatter) {
84
+ return body;
85
+ }
86
+ const raw = typeof frontMatter === "string" ? frontMatter : frontMatter.raw;
87
+ return `${raw}${body}`;
88
+ };
@@ -1,13 +1,13 @@
1
1
  export const defaultMdKitEditorTheme = {
2
2
  background: "#ffffff",
3
- blockGap: "0.75rem",
3
+ blockGap: "0.72em",
4
4
  border: "#d8dee8",
5
5
  codeBackground: "#eef1f4",
6
6
  codeRadius: "0.35rem",
7
7
  fontFamily: "inherit",
8
8
  fontSize: "16px",
9
9
  foreground: "#18212f",
10
- lineHeight: "1.7",
10
+ lineHeight: "1.55",
11
11
  link: "#4f46e5",
12
12
  muted: "#eef1f4",
13
13
  mutedForeground: "#5b6472",
@@ -15,14 +15,14 @@ export const defaultMdKitEditorTheme = {
15
15
  };
16
16
  export const darkMdKitEditorTheme = {
17
17
  background: "#0b1220",
18
- blockGap: "0.75rem",
18
+ blockGap: "0.72em",
19
19
  border: "#314158",
20
20
  codeBackground: "#111827",
21
21
  codeRadius: "0.35rem",
22
22
  fontFamily: "inherit",
23
23
  fontSize: "16px",
24
24
  foreground: "#e5edf7",
25
- lineHeight: "1.7",
25
+ lineHeight: "1.55",
26
26
  link: "#38bdf8",
27
27
  muted: "#172033",
28
28
  mutedForeground: "#94a3b8",
@@ -1,6 +1,7 @@
1
1
  import * as Y from "yjs";
2
2
  export type MdKitMarkdownYjsOptions = {
3
3
  fragmentName?: string;
4
+ ignoreYamlFrontMatter?: boolean;
4
5
  };
5
6
  export declare const replaceMdKitYjsMarkdown: (ydoc: Y.Doc, markdown: string, options?: MdKitMarkdownYjsOptions) => Uint8Array;
6
7
  export declare const markdownToMdKitYjs: (markdown: string, options?: MdKitMarkdownYjsOptions) => Uint8Array;
@@ -3,10 +3,14 @@ import { MarkdownManager } from "@tiptap/markdown";
3
3
  import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProsemirrorJSON, } from "@tiptap/y-tiptap";
4
4
  import * as Y from "yjs";
5
5
  import { createMdKitTiptapExtensions } from "../markdown/createMdKitTiptapExtensions.js";
6
+ import { extractYamlFrontMatter, prependYamlFrontMatter, } from "../markdown/yamlFrontMatter.js";
6
7
  import { normalizeMarkdownSerialization } from "../markdown/normalizeMarkdownSerialization.js";
7
8
  import { prepareMarkdownForEditorHydration } from "../markdown/prepareMarkdownForEditorHydration.js";
8
9
  const defaultMdKitYjsFragmentName = "default";
10
+ const mdKitYjsMetadataMapName = "__mdkit";
11
+ const frontMatterPrefixMetadataKey = "frontMatterPrefix";
9
12
  const getMdKitYjsFragmentName = (options) => options?.fragmentName ?? defaultMdKitYjsFragmentName;
13
+ const getFrontMatterPrefixMetadataKey = (fragmentName) => `${fragmentName}:${frontMatterPrefixMetadataKey}`;
10
14
  const createMdKitMarkdownManager = () => new MarkdownManager({
11
15
  extensions: createMdKitTiptapExtensions(),
12
16
  markedOptions: {
@@ -17,10 +21,22 @@ const createMdKitProseMirrorSchema = () => getSchema(createMdKitTiptapExtensions
17
21
  const markdownToProseMirrorJson = (markdown) => createMdKitMarkdownManager().parse(prepareMarkdownForEditorHydration(markdown));
18
22
  const proseMirrorJsonToMarkdown = (json) => normalizeMarkdownSerialization(createMdKitMarkdownManager().serialize(json));
19
23
  export const replaceMdKitYjsMarkdown = (ydoc, markdown, options) => {
20
- const fragment = ydoc.getXmlFragment(getMdKitYjsFragmentName(options));
24
+ const fragmentName = getMdKitYjsFragmentName(options);
25
+ const fragment = ydoc.getXmlFragment(fragmentName);
26
+ const metadata = ydoc.getMap(mdKitYjsMetadataMapName);
21
27
  const schema = createMdKitProseMirrorSchema();
22
- const json = markdownToProseMirrorJson(markdown);
28
+ const frontMatter = options?.ignoreYamlFrontMatter
29
+ ? extractYamlFrontMatter(markdown)
30
+ : null;
31
+ const json = markdownToProseMirrorJson(frontMatter?.body ?? markdown);
32
+ const metadataKey = getFrontMatterPrefixMetadataKey(fragmentName);
23
33
  prosemirrorJSONToYXmlFragment(schema, json, fragment);
34
+ if (frontMatter?.frontMatter) {
35
+ metadata.set(metadataKey, frontMatter.frontMatter.raw);
36
+ }
37
+ else {
38
+ metadata.delete(metadataKey);
39
+ }
24
40
  return Y.encodeStateAsUpdate(ydoc);
25
41
  };
26
42
  export const markdownToMdKitYjs = (markdown, options) => {
@@ -30,8 +46,11 @@ export const markdownToMdKitYjs = (markdown, options) => {
30
46
  export const mdKitYjsToMarkdown = (yjsState, options) => {
31
47
  const ydoc = new Y.Doc();
32
48
  Y.applyUpdate(ydoc, yjsState);
33
- const json = yXmlFragmentToProsemirrorJSON(ydoc.getXmlFragment(getMdKitYjsFragmentName(options)));
34
- return proseMirrorJsonToMarkdown(json);
49
+ const fragmentName = getMdKitYjsFragmentName(options);
50
+ const json = yXmlFragmentToProsemirrorJSON(ydoc.getXmlFragment(fragmentName));
51
+ const metadata = ydoc.getMap(mdKitYjsMetadataMapName);
52
+ const frontMatterRaw = metadata.get(getFrontMatterPrefixMetadataKey(fragmentName)) ?? "";
53
+ return prependYamlFrontMatter(frontMatterRaw, proseMirrorJsonToMarkdown(json));
35
54
  };
36
55
  export const yjs = {
37
56
  markdownToMdKitYjs,
@@ -7,6 +7,7 @@ export default defineConfig({
7
7
  themeConfig: {
8
8
  nav: [
9
9
  { text: "Quick Start", link: "/" },
10
+ { text: "Plain Text", link: "/plain-text" },
10
11
  { text: "Styling", link: "/styling" },
11
12
  { text: "Shadcn", link: "/shadcn" },
12
13
  { text: "REST", link: "/rest" },
@@ -21,6 +22,7 @@ export default defineConfig({
21
22
  text: "Guide",
22
23
  items: [
23
24
  { text: "Quick Start", link: "/" },
25
+ { text: "Plain Text Editors", link: "/plain-text" },
24
26
  { text: "Styling", link: "/styling" },
25
27
  { text: "Shadcn Plugin", link: "/shadcn" },
26
28
  { text: "REST Backend", link: "/rest" },
package/docs/api.md CHANGED
@@ -39,6 +39,7 @@ Local editing props:
39
39
  - `onChange?: (markdown: string) => void`
40
40
  - `onFocusChange?: (focused: boolean) => void`
41
41
  - `fillHeight?: boolean`
42
+ - `search?: boolean`
42
43
  - `instanceKey?: string | number`
43
44
  - `className?: string`
44
45
  - `style?: CSSProperties`
@@ -50,6 +51,7 @@ Collaborative editing props:
50
51
  - `onChange?: (markdown: string) => void`
51
52
  - `onFocusChange?: (focused: boolean) => void`
52
53
  - `fillHeight?: boolean`
54
+ - `search?: boolean`
53
55
  - `className?: string`
54
56
  - `style?: CSSProperties`
55
57
 
@@ -57,6 +59,10 @@ Collaborative editing props:
57
59
  keep blank space below the last line clickable so it focuses the cursor at the
58
60
  end. Leave it off when the host application owns sizing and scrolling.
59
61
 
62
+ `search` opts the editor into the built-in document search panel. The panel is
63
+ not rendered by default; when enabled, users open it with `Cmd+F` on macOS or
64
+ `Ctrl+F` on Windows/Linux.
65
+
60
66
  The package stylesheet includes reset-resistant markdown rules for headings,
61
67
  lists, code blocks, blockquotes, and links. Styling is controlled with CSS
62
68
  variables on `.mp-lb-mdkit-markdown-editor`. See [Styling](./styling.md) for setup,
package/docs/index.md CHANGED
@@ -113,7 +113,11 @@ export function ConnectedMarkdownEditor({
113
113
  () => createMdKitTrpcAdapter({ client: trpc.mdkit }),
114
114
  [trpc],
115
115
  );
116
- const document = useMdKitDocument({ adapter, documentId });
116
+ const document = useMdKitDocument({
117
+ adapter,
118
+ debounceMs: 1000,
119
+ documentId,
120
+ });
117
121
  const versions = useMdKitDocumentVersions({ adapter, documentId });
118
122
 
119
123
  const collaboration = useMdKitCollaboration({
@@ -0,0 +1,131 @@
1
+ # Plain Text Editors
2
+
3
+ MDKit's connected workflow is not limited to `MdKitEditor`. The document hooks
4
+ and backend adapters work with serialized text, so you can bring a plain text,
5
+ code, JSON, or custom text editor and still use the same storage, autosave,
6
+ checkpoint history, restore, and conflict handling.
7
+
8
+ The one major exception is collaboration. Collaboration is currently a
9
+ markdown/Tiptap capability because it depends on Yjs, ProseMirror, and the
10
+ Tiptap collaboration extensions.
11
+
12
+ ## What Works
13
+
14
+ Any editor can plug into the connected workflow if it behaves like a controlled
15
+ text input:
16
+
17
+ ```tsx
18
+ type TextEditorProps = {
19
+ value: string;
20
+ onChange(value: string): void;
21
+ onFocusChange?(focused: boolean): void;
22
+ readOnly?: boolean;
23
+ };
24
+ ```
25
+
26
+ That is enough for:
27
+
28
+ - loading the current document
29
+ - autosave
30
+ - dirty state
31
+ - conflict detection
32
+ - force save
33
+ - remote resync
34
+ - checkpoint history
35
+ - checkpoint restore
36
+
37
+ The editor does not need to know about MDKit internals. It only needs to receive
38
+ `document.value` and call `document.setContent`.
39
+
40
+ ## Example
41
+
42
+ ```tsx
43
+ import {
44
+ MdKitConflictPanel,
45
+ MdKitDocumentToolbar,
46
+ VersionHistoryPanel,
47
+ useMdKitDocument,
48
+ useMdKitDocumentVersions,
49
+ type MdKitDocumentAdapter,
50
+ } from "@mp-lb/mdkit";
51
+
52
+ function PlainTextDocument({
53
+ adapter,
54
+ documentId,
55
+ }: {
56
+ adapter: MdKitDocumentAdapter;
57
+ documentId: string;
58
+ }) {
59
+ const document = useMdKitDocument({
60
+ adapter,
61
+ debounceMs: 1000,
62
+ documentId,
63
+ });
64
+ const versions = useMdKitDocumentVersions({ adapter, documentId });
65
+
66
+ return (
67
+ <>
68
+ <MdKitDocumentToolbar document={document} versions={versions} />
69
+
70
+ <textarea
71
+ readOnly={document.conflict}
72
+ value={document.value}
73
+ onBlur={() => document.setFocused(false)}
74
+ onChange={(event) => document.setContent(event.currentTarget.value)}
75
+ onFocus={() => document.setFocused(true)}
76
+ />
77
+
78
+ <MdKitConflictPanel document={document} />
79
+ <VersionHistoryPanel controller={versions} />
80
+ </>
81
+ );
82
+ }
83
+ ```
84
+
85
+ Use the same backend adapter you would use for markdown. The document content is
86
+ still just `content: string`.
87
+
88
+ ## Backend Shape
89
+
90
+ You do not need a separate backend for plain text documents. A single MDKit
91
+ backend can expose:
92
+
93
+ - document read/write
94
+ - checkpoint list/read/restore
95
+ - optional collaboration websocket routes
96
+ - optional collaboration state persistence
97
+
98
+ Plain text editors use the document and checkpoint APIs. Markdown collaborative
99
+ editors additionally use the collaboration websocket and Yjs persistence.
100
+
101
+ The underlying database layout is application-owned. It is reasonable to store
102
+ markdown and plain text documents in the same documents table, or in separate
103
+ tables if your product needs that. MDKit only requires a stable `documentId`,
104
+ `content`, and an opaque revision token.
105
+
106
+ ## Collaboration Boundary
107
+
108
+ Do not pass `useMdKitCollaboration` to a plain text editor. The current
109
+ collaboration adapter is for `MdKitEditor` because that editor knows how to bind
110
+ Tiptap to a Yjs document and render remote cursors.
111
+
112
+ For plain text documents:
113
+
114
+ - keep using `useMdKitDocument`
115
+ - omit `useMdKitCollaboration`
116
+ - omit collaboration UI
117
+ - rely on optimistic conflicts and resync for multi-client safety
118
+
119
+ If MDKit later adds a collaboration-capable CodeMirror, Monaco, or textarea
120
+ adapter, that should be a new editor-specific capability. The generic text
121
+ workflow does not need to change.
122
+
123
+ ## Testbench
124
+
125
+ The testbench includes a connected stack named
126
+ `Storage + checkpoints (plain text)`. It reuses the same checkpoints backend as
127
+ the markdown stack, stores content under `docs/plain-text.txt`, and renders a
128
+ controlled textarea instead of `MdKitEditor`.
129
+
130
+ Use it to verify that plain text can autosave, create checkpoints, restore
131
+ history, and avoid collaboration UI.
package/docs/shadcn.md CHANGED
@@ -46,7 +46,11 @@ import { MdKitConnectedWorkflow } from "@/components/mdkit/mdkit-connected-workf
46
46
  export function EditorScreen() {
47
47
  const client = createMdKitTrpcClient({ url: "/trpc" });
48
48
  const adapter = createMdKitTrpcAdapter({ client });
49
- const document = useMdKitDocument({ adapter, documentId });
49
+ const document = useMdKitDocument({
50
+ adapter,
51
+ debounceMs: 1000,
52
+ documentId,
53
+ });
50
54
  const versions = useMdKitDocumentVersions({ adapter, documentId });
51
55
 
52
56
  const collaboration = useMdKitCollaboration({
package/docs/styling.md CHANGED
@@ -120,7 +120,8 @@ you need structural changes, component-specific spacing, or state styling.
120
120
 
121
121
  `MdKitEditor` renders the markdown editing surface and the selection bubble
122
122
  toolbar. The toolbar appears for non-empty text selections while the editor or
123
- toolbar has focus.
123
+ toolbar has focus. The search panel appears only when `search` is enabled and
124
+ the user opens it with the find keyboard shortcut.
124
125
 
125
126
  - `.mp-lb-mdkit-markdown-editor`: root element rendered by `MdKitEditor`
126
127
  - `.mp-lb-mdkit-markdown-editor-fill-height`: added to the root when
@@ -129,6 +130,10 @@ toolbar has focus.
129
130
  - `.mp-lb-mdkit-editor-surface`: scroll and background surface around the
130
131
  ProseMirror editor
131
132
  - `.mp-lb-mdkit-editor-empty`: loading or connecting placeholder
133
+ - `.mp-lb-mdkit-search-panel`: optional document search panel
134
+ - `.mp-lb-mdkit-search-input`: search text input
135
+ - `.mp-lb-mdkit-search-status`: search result count
136
+ - `.mp-lb-mdkit-search-button`: search navigation or close button
132
137
  - `.mp-lb-mdkit-tiptap`: ProseMirror editable element
133
138
  - `.mp-lb-mdkit-toolbar`: selection bubble toolbar
134
139
  - `.mp-lb-mdkit-toolbar-button`: toolbar button
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mp-lb/mdkit",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -94,6 +94,7 @@
94
94
  "lucide-react": "^0.554.0",
95
95
  "react-markdown": "10.1.0",
96
96
  "remark-gfm": "4.0.1",
97
+ "yaml": "2.9.0",
97
98
  "yjs": "^13.6.24",
98
99
  "zod": "^4.1.12"
99
100
  },