• Joined on 2026-01-29

@mai/meditor (0.9.2)

Published 2026-09-28 11:10:19 +00:00 by mAi

Installation

@mai:registry=https://mgit.flexsiebels.de/api/packages/mAi/npm/
npm install @mai/meditor@0.9.2
"@mai/meditor": "0.9.2"

About this package

mEditor

A markdown editor as a Svelte package: a CodeMirror 6 editor and a preview pane, with wikilink and tag autocomplete, a formatting toolbar and keymap, indent guides, a marker table that draws - [c] as an icon, and a provider interface so the host supplies content resolution instead of the editor knowing about any one product's data.

Extracted from mBrian/src/lib/MarkdownEditor.svelte for m/mWiki#4. See docs/provider-interface.md for what a consumer must supply.

Lives at packages/meditor/ in the m/mkit workspace (moved from the standalone m/mEditor repo 2026-09-14, m/mkit#3), alongside packages/mkit (@mai/mkit). The root bunfig.toml and .gitea/workflows/publish.yml are shared across both packages; see the workspace root README.

Install

Published as @mai/meditor on this Gitea instance's own npm registry, not on the public npm registry — under the mAi account's namespace, since that is the account that cuts every release.

bun add @mai/meditor

Point the @mai scope at the registry in a .npmrc (project root or user level):

@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/

The package is readable without a credential, so an install (a Dokploy build included) needs that line and no token. Never add an _authToken line to a consumer: with GITEA_NPM_TOKEN unset bun sends an empty bearer and Gitea answers 401. Publishing needs write:package and runs from this repo's workflow (root README § Publishing); a change here ships a CHANGELOG.d/<issue>-<slug>.md fragment and never a version, which is assigned on main after the merge (m/mkit#62). Gitea's package registry ties write access to the owning account and takes no collaborators, which is why the package lives under mAi, the account that mints every release.

Versioning

d-880df9d8, m's rule for this workspace, also in CHANGELOG.md's header: 0.x for now, a patch for every fix and every addition (a new optional prop, a new export, a new optional provider member — existing callers keep compiling unchanged), a minor only for a change a consumer must react to (a removed or renamed export, a new required provider member, a changed signature, a DOM a consumer's CSS or tests reached into), and 1.0 when m calls the package stable. The exported surface the rule is measured against is Editor and its props, EditorProviders, renderMarkdown and the pure helpers documented in docs/provider-interface.md. A branch never names the number; it writes a CHANGELOG.d/ fragment with bump: patch or bump: minor (root README § Publishing).

A release is followed by a sync commit in m/mWiki (m/mWiki#111, scripts/sync-meditor.ts), since mWiki vendors the built package rather than installing it from this registry.

Use

<script lang="ts">
	import { Editor, type EditorProviders } from '@mai/meditor';

	let value = $state('');
	const providers: EditorProviders = {
		async search(trigger) { /* … */ return []; },
		resolveLink(kind, target, display) { /* … */ return undefined; }
	};
</script>

<Editor bind:value {providers} onsave={() => save(value)} renderOptions={{ blockIds: true }} />

renderOptions (optional) is passed straight through to the same renderMarkdown options a host's own server-side render uses, so the editor's preview stamps data-block/data-lines the same way. Omitted, both stay off.

The preview is sanitised by default, the same as <Markdown> in @mai/mkit/markdown: DOMPurify strips scripts and event handlers, so a document holding a mail, a web clip or an imported file needs nothing from the host. renderOptions={{ trusted: true }} skips DOMPurify and is only for documents the host produced itself. A server render of Editor never renders the preview (it starts in edit mode), so it needs no DOM. Rendering untrusted markdown outside a browser — a unit test, a host's own server-side render — calls setSanitizerWindow(new JSDOM('').window) first; without it renderMarkdown throws rather than return unsanitised HTML. providers.renderMarkdown is not needed for any of this.

Shaping the editor for a host

toolbar={false} hides the formatting row; preview={false} drops the package's Edit/Preview toggle and Ctrl/Cmd+P, for a host with a preview of its own — the editor stays in edit mode, and with both off nothing renders above the document (the Focus button keeps its row when onFocus is set). readOnly takes the input away — CodeMirror's readOnly and editable off, the toolbar's buttons disabled, a transform refused — and placeholder shows while the document is empty; both are live. taskIcons={false} leaves the - [ ] brackets as characters.

Reaching past the props

A host that needs the editor's state, and not only its text, has three ways in, each a prop:

  • bind:view — the CodeMirror EditorView, set when the view mounts and undefined when it is destroyed (preview mode, unmount). Read it for a position under a pointer (view.posAtCoords) or to dispatch a transaction of your own; never write it.
  • onSelect(selection) — a SelectionInfo on every selection or document change and once at mount: from and to as document offsets (from === to is a caret), text, and top/left, the start's client coordinates from coordsAtPos. What a menu on a selection hangs off, for a host that never imports CodeMirror; subtract your own box for local coordinates.
  • extensions — your own CodeMirror extensions (a language pack, a gutter, a keymap), appended after the package's and reconfigured when the value changes, so a pack loaded on demand lands by passing a new array. The package's keymap wins a shared key unless yours is wrapped in Prec.

To run a package transform from your own button — a phone toolbar with no modifier keys — transformCommand(fn)(view) from @mai/meditor or @mai/meditor/headless dispatches it as one transaction and one undo step, the way the package's keys do, and returns false when the transform declined or the document is read-only; transformTransaction, textState and changeBetween are the pieces under it.

A trigger of your own can act instead of insert (onPick) or keep the list open on a widened query (reopen); see docs/provider-interface.md § triggers.

The document's bytes are the host's: a string loaded and saved untouched is identical, and an edit leaves every other line as it was — CRLF or LF, trailing whitespace, tabs, the trailing newline (tests/document.test.ts). A document mixing both line endings keeps its first one from the first edit on.

Theming

The editor reads the host's custom properties and ships fallbacks for a dark surface: --color-bg, --color-bg-secondary, --color-text, --color-text-secondary, --color-border, --color-border-focus, --color-link, --color-selection, --color-indent-guide and --settings-editor-font-size. The suggestion list and the toolbar's More menu paint from the same set. Set them on any ancestor of <Editor>. The task icons take the kit's --mk-task-* tokens when @mai/mkit/tokens.css is loaded and draw in the text colour otherwise.

The face and the floor are custom properties too, with the package's own values as fallbacks: --md-editor-font (the document's font family, 'JetBrains Mono', 'Fira Code', 'Consolas', monospace), --md-editor-font-mono (the suggestion hint's), and --md-editor-min-height (the editor's and the preview's floor, 300px) — a dock sets its prose face and 0, with no selector against the theme CodeMirror injects.

wikilinks.ts's pure functions (parseWikilinks, extractWikilinkSlugs, rewriteWikilinkTarget) are exported alongside Editor for a consumer's own backlink and rename logic — see docs/provider-interface.md's "Renaming" section for why the rename itself is not part of this package.

Formatting toolbar

A row of insert-action buttons above the editor, shown by default; pass toolbar={false} to hide it. Overflow beyond 320px collapses into a "More" menu.

Action Shortcut
Bold Ctrl/Cmd+B
Italic Ctrl/Cmd+I
Heading (cycles ##→###→####→plain) Ctrl/Cmd+Shift+H
Link Ctrl/Cmd+K
Wikilink (opens the [[ search) Ctrl/Cmd+Shift+K
Inline code Ctrl/Cmd+E
Code block Ctrl/Cmd+Shift+E
Quote Ctrl/Cmd+Shift+.
Bullet list Ctrl/Cmd+Shift+8
Numbered list Ctrl/Cmd+Shift+7
Outdent (the same as the key) Shift+Tab
Indent (the same as the key) Tab
Table —
Footnote Ctrl/Cmd+Shift+F
Task (cycles text→- →- [ ] →- [x] →plain) Ctrl/Cmd+Enter
Task state (steps the nine states) Ctrl/Cmd+Shift+Enter
Format the document (§ Formatter, formatRules prop) Ctrl/Cmd+L

Every button and shortcut runs the same pure text transform, in toolbar-actions.ts — wrapBold, wrapItalic, wrapInlineCode, insertLink, insertWikilink, insertTable, toggleQuote, toggleBulletList, toggleNumberedList, toggleCodeBlock, cycleHeading, insertFootnote, cycleTask, cycleTaskState — each taking { text, selectionStart, selectionEnd } and returning the same shape, exported from both @mai/meditor and @mai/meditor/headless for a consumer that wants to drive them without the UI (a bulk-reformat script, a test). In the editor each is one CodeMirror transaction, so one undo step. Bold and italic unwrap when the marker already stands around the selection, and a * that belongs to a ** pair is not an italic marker.

Enter is not on the toolbar: on a list line it continues the list — the same bullet, the next number for an ordered item, an open [ ] after a task in any state, the text right of the caret carried onto the new item — and on an item with nothing on it ends the list instead; off a list line it is a plain newline. Tab and Shift+Tab, the keys behind the Indent and Outdent buttons, indent and outdent every line the selection touches by one step of two spaces (INDENT), blank lines left alone. Both are pure functions in list-keys.ts (continueList, indentLines, indentList, parseListLine), exported like the toolbar's.

Indent guides and the wrapped-line indent

A list item that wraps keeps every visual line under its own text, and an indented line carries one thin guide per indent step (two spaces, or one tab) down the full height of the line — a wrapped paragraph keeps its guides across every visual line. The guide colour is --color-indent-guide, falling back to --color-border.

A line action applies to every line the selection touches, decided once from the first of them, so a block moves together. A caret keeps its place in its line; a selection ends up around the changed block.

Task icons

A task reads as an icon while the markdown under it stays what the file holds (m/mkit#51): the editor draws the kit's .mk-task over each - [c] marker, and the preview renders the same icon through renderMarkdown. The states are Obsidian's nine — [ ], [x], [/], [?], [!], [-], [>], [<], [*] — defined in @mai/mkit/markdown and themed from the kit's --mk-task-* tokens (the kit's README, § Task states).

Ctrl/Cmd+Enter is the task key: text → - text → - [ ] text → - [x] text → text, with a line in any other state counting as an open task, so one press marks it done. Ctrl/Cmd+Shift+Enter steps the state through the nine and round again, and turns a line that is no task yet into one.

The icon is a CodeMirror decoration over the marker's three characters: the document keeps - [c], the caret steps over the icon as one unit, and Backspace behind it takes the whole marker. A marker the caret or the selection touches — the caret at either edge of it, or a selection over any part — shows its characters instead, each taking the caret and a keystroke, and the icon returns once the selection has left; the caret in the item's own text leaves the icon as it is (m/mkit#118). taskIcons={false} leaves the raw brackets visible, for a plain source view; the preview renders icons either way.

The marker table

Which text is drawn as which icon is a table, DEFAULT_MARKERS (exported), with one row per kit state; the markers prop replaces it, so a host extends the set by passing [...DEFAULT_MARKERS, row]. A row is a MarkerRule:

{
	name: 'money',                                   // data-task on the icon
	match: /^[ \t]*[-*+][ \t]+(\[\$\])(?=\s|$)/,        // per line; group 1 is what the icon covers
	icon: '$',                                       // the glyph; omitted → the kit's --mk-task-glyph-<name>
	color: 'gold',                                   // omitted → the kit's --mk-task-color-<name>
	done: false,                                     // true dims the line and strikes its text
	label: 'Money'                                   // the icon's aria-label
}

The default rows carry no icon or color, so they stay themed by the kit's --mk-task-* tokens; done and cancelled carry done: true. A rule needs no bracket at all — a letter that opens a bullet (- Q said) is a row like any other.

Adding an autocomplete trigger

[[ and # are the two rows of DEFAULT_TRIGGERS (exported); the triggers prop replaces the set the same way. A TriggerRule is a kind (what search receives as EditorTrigger.kind), a match run against the text from the line start to the caret with group 1 as the query, and insert(insertText), the text written over the trigger and the query when a suggestion is picked:

const mention: TriggerRule = { kind: 'mention', match: /(?<=^|\s)@([\w/.-]*)$/, insert: (t) => `@${t}` };
<Editor bind:value {providers} triggers={[...DEFAULT_TRIGGERS, mention]} />

providers.search then sees kind: 'mention' beside the two it knows. The preview's resolveLink is unchanged: it resolves wikilinks and tags, which are the two the renderer knows.

Formatter

formatMarkdown(text, rules?) rewrites a whole document to a rule set and returns it — the Ctrl+L formatter of m/mAi#408, headless (m/mkit#92, on markdownlint since m/mkit#102). It runs only when a human invokes it, never on load, save or blur: the diff a pad's reader takes off the document is the human's own. FormatRules is markdownlint's configuration object, passed through verbatim — one key per rule, MD004: { style: 'dash' }, MD047: true, MD013: false, the long names (ul-style) too; a consumer's .markdownlint.yaml is the value. A rule not named is off: the formatter prefixes default: false, so formatMarkdown(text, {}) returns the input byte for byte, and a configuration that names default itself wins. extends is not resolved. Without a rules argument it applies defaultFormatRules: MD022, MD032 and MD031 (a blank line around headings, lists and fences), MD012 { maximum: 1 } (blank-line runs cut to one, trailing blank lines with them), MD009 { br_spaces: 2, strict: true } (trailing whitespace removed, a hard break keeps its two spaces), MD047 (exactly one trailing newline). The marker choices — MD004 (ul-style), MD029 (ol-prefix), MD049 and MD050 (emphasis-style, strong-style) — are off until a configuration names them; MD003 (heading-style) reports and carries no fix, so setext headings stay as written.

markdownlint's lint reports each violation with a fixInfo and applyFixes applies them, each a line-level edit — a column, a delete count, an insert — so a line ending, a tab, a wikilink, a #tag, a - [c] marker or a code block's content is never rewritten by a rule that is not about it. Two rules of markdownlint's own default set would be: MD018 (no-missing-space-atx) turns #tag at line start into a heading and MD010 (no-hard-tabs) replaces tabs, which is why the kit's default set names its rules and never default: true. An inline <!-- markdownlint-disable --> comment and YAML front matter are honoured as markdownlint honours them. tests/format.test.ts holds the fixtures: a copy of a mai scratchpad and a CRLF one, byte-identical with every rule off, and each default rule render-neutral on the scratchpad through renderMarkdown.

import { formatMarkdown, defaultFormatRules } from '@mai/meditor/headless';

const formatted = formatMarkdown(text, { ...defaultFormatRules, MD004: { style: 'dash' } });

In the editor, Ctrl/Cmd+L runs it over the whole document as one transaction (one undo step) with the formatRules prop, defaultFormatRules unless the host passes its own; it is bound to the key alone and never runs on load, save or blur. Never the selection — a selection boundary inside a list or a fence hands the linter half a block. The key loads markdownlint on the first press, as a chunk of its own (§ What a page without an editor carries), so the first format lands a tick after the key and every later one is immediate. The document it formats is the one the editor holds when the chunk arrives, so a character typed in between is formatted with the rest.

Plain resolvers (no Svelte, no Vite)

The package root (@mai/meditor) resolves only through the svelte export condition, because Editor is a Svelte component and no export condition makes a .svelte file loadable by a plain resolver. A server-side consumer that only needs renderMarkdown or the wikilinks helpers — bun test, a plain Node ESM script, anything without a Svelte-aware bundler — imports from the @mai/meditor/headless subpath instead, which never touches Editor.svelte:

import { renderMarkdown } from '@mai/meditor/headless';

What a page without an editor carries

An import of the helpers alone — from either entry — carries neither CodeMirror nor markdownlint, and none of the editor's stylesheets. import { parseWikilinks } from '@mai/meditor' is 1.1 kB; the task helpers and renderMarkdown add the kit's renderer and its prose.css, 27.9 kB gzip, and nothing else. A page that mounts the Editor carries CodeMirror; markdownlint stays out of that chunk too and arrives on the first Ctrl/Cmd+L, 44.6 kB gzip a reader who never formats never loads (m/mkit#285).

Two things hold that boundary, and a consumer's bundler needs both: the sideEffects field in package.json, which is what lets Rollup drop a module the entry re-exports but nobody uses, and the dynamic import() of format.ts behind the format key. tests/bundle.test.ts bundles both entries the way a consumer does and reads the module list back off the chunks, so a static re-export added to index.ts fails a test rather than a consumer's bundle report.

Develop

bun install
bun run test:unit # tests/*.test.ts: the pure modules incl. format.ts, the byte-stability round trip (tests/document.test.ts), the server render (tests/ssr.test.ts); packages/mkit must be built first, its exports point at dist/
bun run dev       # src/routes is a standalone test consumer on 127.0.0.1:5183 (MEDITOR_PORT picks another), no other repo needed; the component in a browser is checked by hand here
bun run build     # builds the package into dist/

Dependencies

Dependencies

ID Version
@codemirror/commands ^6.11.0
@codemirror/state ^6.7.4
@codemirror/view ^6.43.11
@mai/mkit ^0.2.35
markdownlint ^0.41.1

Development Dependencies

ID Version
@sveltejs/adapter-auto ^4.0.0
@sveltejs/kit ^2.15.0
@sveltejs/package ^2.3.0
@sveltejs/vite-plugin-svelte ^5.0.0
@types/bun ^1.3.9
@types/jsdom ^30.0.0
jsdom ^30.0.1
publint ^0.2.0
svelte ^5.0.0
svelte-check ^4.0.0
typescript ^5.0.0
vite ^6.0.0

Peer Dependencies

ID Version
svelte ^5.0.0
Details
npm
2026-09-28 11:10:19 +00:00
56
MIT
39 KiB
Assets (1)
Versions (20) View all
0.9.4 2026-09-29
0.9.3 2026-09-28
0.9.2 2026-09-28
0.9.1 2026-09-28
0.9.0 2026-09-25