Instruction set for AI agents working on Draftly.
CLAUDE.md is a symlink to this file — edit this one.
A pluggable markdown editor and static previewer built on CodeMirror 6, published to npm
as draftly. The repo is a Turborepo monorepo managed with Bun.
Two ideas carry the whole design:
- The document is always plain markdown text. There is no secondary document model. Richness is CodeMirror decorations layered on top, which retract when the cursor enters a construct to reveal the raw syntax.
- A plugin owns a markdown feature end to end — its parser extension, editor decorations, keymap, theme, and its static HTML renderer. One class, one feature, both surfaces. This is what makes editor/preview parity structural rather than aspirational.
Read artifacts/architecture/overview.md before
your first substantive change.
Before touching any code, in this order:
artifacts/memory.md— durable facts, traps that have cost time, the developer's preferences, and open questions. Non-negotiable; it exists precisely because the codebase's sharpest edges are invisible from a quick read.artifacts/tasks/index.md— what is in flight, what is blocked, what has shipped.artifacts/architecture/index.md— pick the documents relevant to your task from the "open it when" column. Do not read all of them by default.artifacts/repository-map.md— if you need to locate something.
Then load the relevant skill:
| Working on | Read first |
|---|---|
| Decorations, view plugins, facets, extensions, keymaps, widgets | .agents/skills/codemirror/ — including references/architecture.md and references/extensions.md |
turbo.json, workspaces, caching, CI, filtering |
.agents/skills/turborepo/ — including the topic file under references/ |
Both skills have references/ subdirectories with far more depth than their SKILL.md.
Read the specific topic file, not just the summary.
Non-negotiable, additionally:
- Read
artifacts/architecture/plugin-table.mdbefore any edit toplugins/table-plugin.ts. Several of its mechanisms look like accidents and are not.
If the code contradicts an architecture document, a README claim, or a comment — stop and ask the developer. Do not silently pick one and "fix" the other. The document may be describing intent the code has drifted from, and that gap is information.
Six such conflicts are already logged in
artifacts/memory.md. Add to that
table rather than resolving unilaterally.
- One feature, one plugin file. Cross-plugin coupling is the thing this architecture exists to prevent.
editor/never importsplugins/. Plugins are injected by the caller. Preserving this keeps the library tree-shakeable.- Keep pure logic pure. Text utilities, parsing, and formatting must not acquire a CodeMirror dependency. This is why the table plugin's lowest layers are testable.
- Layer boundaries are real. Identify the layer you are editing and stay inside it.
- ~500 LOC is the plugin ceiling. Split into a directory before exceeding it. Two files already exceed it and are tracked as debt, not precedent.
Prefer a new small module over growing an existing file. Prefer a pure function over a method that reaches into state. Prefer injecting a dependency over importing a singleton.
Every exported symbol — class, function, type, interface, constant — carries JSDoc.
Follow the existing style in editor/plugin.ts and editor/draftly.ts.
/**
* Build heading decorations by iterating the syntax tree.
*
* @param ctx - Decoration context with view and decoration array
* @returns Nothing; decorations are pushed into `ctx.decorations`
*/- Document why, not what the code already says.
@paramfor every parameter,@returnswhere non-void,@exampleon public API.- Non-obvious constants get a JSDoc line explaining the choice — especially
decorationPriorityvalues. - Match the surrounding file's comment density. Do not add narration to code that reads clearly on its own.
Commit only correlated edits. Never everything at once.
- One logical change per commit. A refactor and a bug fix are two commits, even in the same file.
- Code and its artifact updates belong in the same commit — a doc that lands a commit later is a doc that will be forgotten.
- Format:
type(scope): Description— matching the existing history.- Types:
feat,fix,refactor,docs,chore - Scopes:
draftly,web,ui, or omit for repo-wide - Examples:
feat(draftly): Emoji Plugin,fix(draftly): Ignore tailing non-table line
- Types:
- Do commit as you go. Do not stage a large change and commit it all at once.
- Do Not branch off master unless told; do not push unless asked.
- Add a changeset (
bun changeset) for any user-facing library change.
| After you… | Update |
|---|---|
| Learn something non-obvious, or get corrected | artifacts/memory.md |
| Start, advance, or finish work | artifacts/tasks/ + its index |
| Change structure, contracts, or mechanisms | the relevant artifacts/architecture/*.md |
| Add or move a directory | artifacts/repository-map.md |
Update the front-matter Last verified line and commit hash when you revise a document.
Write to memory what a future agent could not cheaply re-derive: constraints, "why" decisions, dead ends, developer preferences. Do not write what the code or git history already says.
There is no test suite (tracked as T-001). Verification is manual:
bun dev # playground with hot-reloaded library
cd packages/draftly && bun run typecheck # tsc --noEmit
bun run lint # biome check — read-only, fails on errors
bun run check:fix # biome check --write — lint fixes + formatLint and format are Biome, not ESLint/Prettier. lineWidth is 120. Shared presets live
in packages/biome-config; each workspace's biome.json extends base plus its framework
layer. Suppress with // biome-ignore lint/<group>/<rule>: <reason> — the reason is
mandatory, and the comment goes above the line the diagnostic anchors to.
For a library change, work through the 8-step playground checklist in
artifacts/architecture/web-playground.md.
The short version: check the editor pane, the preview pane, the HTML pane, and
the CSS pane; toggle your plugin off; toggle dark/light.
Report honestly. If you could not verify something, say so.
The full list is in artifacts/memory.md. The ones that bite most:
requiredNodesis the preview dispatch key. A plugin withrenderToHTML()but an emptyrequiredNodesis silently dead in preview. First thing to check when something renders in the editor but not the preview.- Never walk the tree unbounded in
buildDecorations. Usectx.iterateVisible;syntaxTree(view.state).iteratecosts O(document) on every keystroke and every cursor move. Fixed library-wide in C-016; the twoTablePluginfacet computations are the only deliberate exceptions. buildDecorationserrors are swallowed only while the tree is still parsing. Genuine errors are reported viaDraftlyConfig.onPluginErroror a dev-onlyconsole.error, deduplicated per plugin and message. If a decoration does not appear and nothing was logged, nothing threw — the bug is in the logic.Decoration.replacemust never span a newline. Clamp toline.to. Canonical example:heading-plugin.ts:104.- Release view-scoped state in
onViewDestroy. A plugin instance outlives the view that used it;EditorViewhas no public "destroyed" flag, so guard in-flight async work with aWeakSetastable-plugin.tsdoes.onUnregisteris deprecated and never called. - Build plugins with
createEssentialPlugins()/createAllPlugins(), one set per editor. Sharing one set across two editors makes them overwrite each other's config and cancel each other's table normalization (C-026). The old shared-singletonessentialPlugins/allPluginsarrays were removed in C-028. - Never import a heavy plugin from the
pluginsbarrel.MermaidPlugin,MathPluginandEmojiPluginlive atdraftly/plugins/{mermaid,math,emoji}, andcreateAllPlugins()lives atdraftly/plugins/all. tsup concatenates everything reachable from an entry point into one chunk, and a chunk's top-levelimport mermaidruns whenever any binding in it is used — so one barrel re-export puts 5.3 MB back on every consumer (C-027). Adding a plugin with a heavy dependency means adding an entry point, not a barrel line. - A widget's
eq()must compare content only. Comparingfrom/tomeans it is never reused; useresolveWidgetRange()when a handler needs the range. - Never dispatch a transaction from
buildDecorationsorupdate(). Use theschedule*deferred pattern fromtable-plugin.ts, and annotate self-dispatches. ThemeEnum.AUTOdoes not detect the system theme. It applies thedefaultlayer only.sanitize()is a no-op on the server. DOMPurify needs a DOM;sanitize: truegives no SSR protection.ctx.sanitize()does nothing useful for an attribute value. It parses an HTML fragment; a bare string is not one, so it comes back unchanged — quotes included. Escape attributes withescapeHtml; sanitize only real fragments.wrapperClassmust match betweenpreview()andgenerateCSS(), or output is unstyled.- Bun only.
pnpm installornpm installcreates a conflicting lockfile. - CodeMirror packages stay external/peer. Two copies of
@codemirror/statebreaks facet identity. - Bump
VERSIONinapps/web/app/playground/page.tsxafter editing seed markdown inapp/data/md/, or returning users keep the cached copy.
- Read
artifacts/architecture/plugin-system.md. - Create
packages/draftly/src/plugins/<feature>-plugin.ts. - Extend
DecorationPlugin(orDraftlyPluginif render-only). Setname,version,decorationPriority(pick within an existing band), andrequiredNodes. A higherdecorationPrioritywins on both surfaces — it layers on top in the editor and is consulted first in preview. Two plugins on one node at equal priority warn in dev. - Hoist
Decorationinstances to module scope — allocating per keystroke is a real cost. Walk the tree withctx.iterateVisible, neversyntaxTree(view.state).iterate— the context carries the viewport bounds, and an unbounded walk costs O(document) on every cursor movement. - In
renderToHTML, read class names off the decoration specs (someDecoration.spec.class) rather than retyping them. This is the mechanism that enforces parity. - Guard every hiding decoration with
ctx.selectionOverlapsRange(from, to). - A widget's
eq()compares content, neverfrom/to— positions shift on any edit above it, so the widget would be rebuilt every keystroke. Resolve the range at event time withresolveWidgetRange()fromdraftly/lib. - Escape attribute values, sanitize fragments, and run URLs through
safeUrl().ctx.sanitize()is for a blob of HTML that stays markup; anything going into a quoted attribute or rendered as text getsescapeHtmlfromdraftly/lib. ApplysafeUrl()on both surfaces, not just preview. - Do not hold view-scoped state on the plugin. Anything derived from a specific
EditorView— a pending timer, a scheduled microtask's target, the view itself — keys off the view (aWeakMapor aStateField) or is released inonViewDestroy. One instance belongs to one editor, and a retained view retains its whole document._config/_contextare the sanctioned exception: written once at composition time. - Put the theme at the bottom of the file via
createTheme(). - Register in
plugins/index.ts— named export and thecreateEssentialPlugins()factory. If the plugin pulls a heavy third-party dependency, it goes in its own entry point instead (src/plugins/<name>.ts, plustsup.config.tsand theexportsmap) and is added tocreateAllPlugins()inplugins/all.ts— never to the barrel. - Add a row to
artifacts/architecture/plugins-catalog.md. - Extend
apps/web/app/data/md/walkthrough.tsand bumpVERSION. - Verify both surfaces in the playground.
Only for feature-agnostic changes — the test is whether two unrelated plugins would
both want it. Read
artifacts/architecture/editor-core.md first.
Anything markdown-feature-specific is a plugin, not core.
Usually a plugin's renderToHTML(). Read
artifacts/architecture/preview-pipeline.md
before touching preview/, especially syntax-theme.ts, which depends on undocumented
CodeMirror internals.
- Do commit as you go. Do not stage a large change and commit it all at once.
- Do not push, publish, or release unless asked.
- Do not change the public API surface without flagging it —
draftlyis published and consumers depend on it. - Do not add a runtime dependency to
packages/draftlywithout asking; bundle size is a design constraint (it is why the heavy plugins sit behind their own entry points). - Do not move CodeMirror packages out of peer/external.
- Do not put library features in
apps/web. The playground is a consumer, exactly like an external user's app. Shared helpers belong inpackages/draftly/src/lib/. - Do not resolve a conflict between docs and code on your own — ask.