dsh-composer-markdown
No description
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 7, 2026
- Updated
- Sep 7, 2026
Introduction
dsh-composer-markdown
In one sentence: a pure client-side plugin for DSH (DeepSeek Harness) Web that adds Markdown editing aids to the conversation composer — list auto-continuation and renumbering, inline-code styling (backticks hidden once rendered), and code-fence auto-closing with atomic block interactions. Every editing gesture rides on Shift+Enter; Enter keeps DSH's native "submit" semantics and is never intercepted. Apart from the ordered-list renumbering, everything is edit-state visuals and key gestures only — the submitted text always stays literal Markdown, byte-for-byte faithful.
- Platform: web (the DSH Web GUI,
dsh plugin --profile web) - Shape: a pure client plugin — the host half is a no-op carrier, zero runtime dependencies, toggleable any time in "Settings → Plugins" (设置 → 插件)
- Tests: 426 assertions passing (a zero-dependency runner: artifact freshness + pure-logic unit tests + bundle-shape assertions + an optional host-contract smoke)
- License: MIT
Contents
- Purpose and use cases
- Feature overview
- Installation
- Usage
- Architecture
- Compatibility
- Known limits
- Development notes and conventions
Purpose and use cases
The DSH composer is a Lexical plain-text editor that natively offers only "Enter submits / Shift+Enter soft-breaks" — writing Markdown means typing every literal character by hand. This plugin brings the composer's Markdown editing experience up to the level of mature chat products:
- Write structured content in the input box: lists, steps, code snippets, code blocks — without leaving the keyboard for a toolbar;
- Make literal Markdown readable:
•bullet markers, monospace numbering, the inline-code background, and paired```fences rendering as a real code block — what you see approaches how the sent message renders; - Keep the plain-text contract: every enhancement is an edit-state visual layer; what gets sent is exactly the literal Markdown you typed, byte-for-byte — the model side, the message renderer, and the clipboard projection are untouched;
- Change no native habit: Enter still submits; the
/and@menus,@xxxreference chips, image pastes, and IME input all behave exactly as before.
Not for: rich-text (non-Markdown) editing, or rewriting/beautifying Markdown at send time — this plugin deliberately performs no send-state transformation.
Feature overview
you type edit-state rendering sent text
─────────────────────────────────────────────────────────────────────
- apple • apple (bullet marker) - apple
⇧↵ (Shift+Enter) - ␣ (next line auto-prefixed) …
1. step one 1. step one (monospace digits) 1. step one
⇧↵ 2. ␣ (auto-incremented, ...) …
`code` code (mono+bg, backticks hidden) `code`
```ts ⇧↵ ┌─────────────────┐ ```
│ ts badge │ (blank)
│ ␣ (caret here) │ ```
└─────────────────┘
By domain:
| Domain | Capabilities |
|---|---|
| Lists | bullet/ordered continuation (any visual line and any nesting depth, including soft-broken and pasted lines), split-at-caret continuation, empty-item exit, the per-group renumbering invariant (delete closes gaps / breaks restart / merges accumulate / nesting-aware), mid-insert shift-down, the level ladder (Tab sinks, Shift+Tab or Backspace-after-the-atom lifts — whole subtree rides along; a top-level lift unlists back to plain text), atomic atom delete (indent included), atomic arrow hops over atoms (indent included), no resting inside an atom, differentiated marker rendering (• dot / monospace digits) |
| Inline code | paired-backtick inner text styled as inline code, backticks hidden the instant the pair closes (zero-advance invisibility, real font metrics kept), revealed while a selection genuinely covers them (select-what-you-see) |
| Code blocks | ``` / ```ts + Shift+Enter skeleton closing, the type-```-at-a-content-line-head flow that preserves the tail, commit-only paired rendering (whole-block code look + floating language badge + zero-height marker lines), one-keystroke unwrap at block boundaries (markers removed, body kept), ↑/↓ skipping the marker lines, Shift+Enter inside a fence inserts a paragraph, fences open at the head of any line (soft-line promotion), orphan close-marker cleanup |
Installation
Prerequisites
- DSH
0.1.2-alpha.x(the composer is a Lexical plain-text editor +@lexical/plain-text, lexical0.49); - Node.js
^22.19.0 || >=24.0.0— the same window DSH itself requires; the package'senginesfield mirrors it; - a modern desktop browser (Chrome / Edge / Firefox / Safari).
Installing the plugin
The package declares a dsh.bundle.patch (cordis.patch.yml: one insert row mounting the host half's no-op entry, which makes the plugin appear in "Settings → Plugins").
From a local checkout (the usual development route):
dsh plugin --profile web add link:/absolute/path/to/dsh-composer-markdown
From the npm registry:
dsh plugin --profile web add dsh-composer-markdown
From GitHub (the built client.js is committed, so the plugin loads even when pnpm skips the prepare build script; pin a commit with #<sha> so a later push cannot change what you run):
dsh plugin --profile web add github:chendefine/dsh-composer-markdown
pnpm ≥ 10 blocks a git dependency's
preparescript until allowed. If you want the install to rebuildclient.jsfromsrc/client/, add the key pnpm prints (e.g.dsh-composer-markdown: true) underallowBuildsin the profile'spnpm-workspace.yamland re-run theadd— treat it as permission to run the package's code on your machine at install time.
Or through the DSH plugin marketplace (设置 → DSH插件市场) — tag the repo with the dsh-plugin topic and it is indexed automatically.
After installing, restart the dsh web service and refresh the browser page. With a link: install, edit the sources under src/client/, run node scripts/build.mjs to regenerate client.js — in dev mode HMR hot-reloads the regenerated artifact (no refresh needed; refresh once if HMR is not running).
Toggle and uninstall
- Toggle: Settings → Plugins (设置 → 插件) →
composer-markdown; disabling restores all native behavior (effective after a refresh); - Uninstall:
dsh plugin --profile web remove dsh-composer-markdown.
Disable/uninstall auto-cleans: the document listeners, the injected style tag, and every code-style format bit in the editor (the draft text content is unchanged).
Usage
Every editing gesture rides on Shift+Enter; Enter = native submit, never intercepted; a Shift+Enter that matches no scenario = the native soft break. Tab / Shift+Tab are claimed only when the caret's line is a list item (the level ladder) — everywhere else they stay native (focus traversal and the like). Plain Backspace / Delete / arrow keys are claimed only when they hit exactly the boundaries below — everything else keeps the native per-character behavior.
Lists
| Operation | Trigger | Behavior |
|---|---|---|
| Bullet/ordered continuation | Type content after a line-head - / * / N. (any indent depth), press Shift+Enter | The new line below is auto-seeded with the {indent}- / {n+1}. prefix (nesting level preserved), caret right after it; works on any visual line — paragraph heads, soft-broken lines, and multi-line-pasted lines alike |
| Split at caret | Shift+Enter with the caret mid-content of a list item | The line is cut at the caret: everything after it moves down and becomes the new item's content, spliced after the new prefix (byte-faithful); a caret at the line start / inside the marker / at the line end keeps the append-below behavior |
| Mid-insert shift-down | Shift+Enter inserting a new item mid-group | The new line takes n+1 and every member below in the same group shifts +1 (1. 2. 3. inserting between 1 and 2 → 1. 2. 3. 4.); the group stays continuous and duplicate-free; insert and shift share one undo step |
| Renumbering (the invariant) | After any change (delete/insert a line, empty-item exit, paste, undo/redo) | Every ordered group (consecutive ordered lines, one indent, outside fences) is always numbered 1. … n. from its first member: a deleted line closes the gap below; a group split by a plain/blank/bullet line restarts its tail at 1.; deleting the splitting line merges the two runs into one continuous count; deeper indents merely suspend the outer group (a nested sublist never interrupts the outer run — its count resumes when the outer indent returns), nested groups normalize independently. Deliberate trade: a group cannot hold a non-1 start or a manual gap — hand-typed digits snap back (continuity IS the contract) |
| Empty-item exit | Shift+Enter on an item that is only a prefix | The prefix is removed and the empty paragraph kept (an empty line on soft lines) — press twice to cleanly leave the list |
| Level ladder — sink | Tab with the collapsed caret anywhere on a list item's line (mid-content included) | The item and every deeper line below it (nested sublists, deeper-continuation text — the whole subtree, recursively) each gain 2 spaces of indent; same-level siblings and everything after them are untouched; the caret rides its in-line position; the whole subtree moves in one keystroke, one undo step; the reshuffled ordered groups normalize on the invariant right after (a 2. nested under a parent renumbers to that nested run's 1.). Sinking is capped (v2.9): an item may sit at most ONE level below its parent — the nearest item line above (plain lines skipped, blanks/fences ending the search). An item already one level below its parent, and a list's FIRST item (nothing above to nest under), get a claimed-but-inert no-op on Tab (focus never jumps away mid-list-editing) — the ladder cannot descend without bound |
| Level ladder — lift / unlist | Same positions, Shift+Tab — or Backspace with the collapsed caret right after the list atom (at the content head) | A nested item (indent ≥ 1 space) → the item and its whole subtree each lose min(2, indent) spaces (odd hand-typed indents floor at 0); a top-level item → unlisted: the whole atom dies, the content stays as a plain-text line, the subtree still rises one level. The plain line an unlist leaves behind splits an ordered group — the same-indent members below restart at 1. per the invariant. Holding Tab sinks once and then holds at the cap; holding Shift+Tab climbs one rung per repeat — focus never escapes an item line on either key |
| Atomic atom delete | Delete with the collapsed caret right at a list item's line head (before the whole atom) | The indent + marker atom goes in one stroke (␣␣1. — all of it), the line becomes top-level plain text, its own undo step; the indent can never be eaten character by character |
| Atomic arrow hop over atoms | Plain ← / → with the collapsed caret at any list-atom boundary | → at the line head jumps clear over the whole ␣␣1. / ␣␣- (indent included) to the content's first character; ← right after the atom jumps back to the line head; a caret already inside the atom (including inside the indent spaces — a click, a vertical move) exits to the far edge — the keyboard never walks the atom interior. Shift+arrows stay native (a selection can still cover exactly the digit characters for manual renumbering) |
| Differentiated marker rendering | Any visual line headed by - / * / N. (outside fences, empty prefixes included) | Ordered digits switch to the code font; the bullet dash/star renders as • (dot + space as one marker unit); the marker bytes are kept verbatim in the draft/copy/send text; a selection genuinely covering a marker character reveals the raw character |
| No resting inside an atom | A collapsed caret landing inside a list atom (indent spaces included) via any path (↑/↓, mouse click) | The caret is homed to the nearest atom edge (ties snap to the line head); the edges themselves (line head / content head) are legal rest points; selections are unaffected |
Inline code
- When a line holds a paired
`non-empty`whose content stays on one line and whose inner-edge characters are non-blank (full-width space / NBSP included): the inner text gets the inline-code look (code font, light background, rounded corners — reusing the DSH theme tokens); - The paired backticks hide the instant the pair closes (invisible, zero width, still kept byte-for-byte in the draft and the sent text); they stay hidden while the caret travels inside the pair or brushes either side;
- Only a selection genuinely covering one of the backtick characters (Shift+arrows, select-all) temporarily reveals the pair (select-what-you-see); it hides again once the selection collapses; a cross-paragraph selection (Ctrl+A) projects per paragraph — no misses at paragraph boundaries;
- Non-matching pairs are fully inert (their backticks stay visible and never affect later pairing);
``-style multi-backtick delimiters are not recognized (naive left-to-right pairing).
Code blocks
| Operation | Trigger | Behavior |
|---|---|---|
| Skeleton closing | The caret's line is exactly ``` (optionally with a language id ```ts) and the caret sits inside/after the marker run, Shift+Enter | Inserts the three-line skeleton ``` / empty / ```, caret at the head of the empty line; works when typed at the head of any line (soft-broken and pasted lines included); a caret before the marker run (bare ``` line, offset 0) is plain text and keeps the native soft break |
| Content-preserving flow | Type ``` at the head of a line that already has content (caret still right after it), Shift+Enter | Splits after the ```: the marker line keeps ```, an empty line + closing skeleton is inserted, and everything after ``` on the original line moves below the closing marker (outside the block, byte-faithful); the language-id flow is unaffected (```ts at line end still yields the badged skeleton) |
| Paired rendering | A paired ```…``` region in the draft | The whole region renders as one code block: code background + code font inside (the same theme tokens and geometry as the message-side CodeBlock); the first marker line is zero-height, the language id shows as a floating badge at the block's top-right; the ``` markers are fully imperceptible (invisible, never host the caret); an unclosed ``` is plain text, always — typing ``` mid-draft never swallows the content below into a block |
| One-keystroke unwrap | Backspace / Delete at a block boundary (← at the first body line's head / the paragraph after the block; → at the paragraph before the block / the last body line's end) | One keystroke removes the pair of ``` marker lines: the block un-renders, the body survives verbatim as plain paragraphs, the caret stays put — its own undo step |
| Leaving the block | ↓ at the last body line's end (an empty paragraph grows below when the block ends the draft) | The caret drops to the new paragraph below the block to continue typing plain text; ↑/↓ skip the marker lines at block boundaries |
| New paragraph inside a fence | Shift+Enter inside a fence (the closing line included) | Inserts a new paragraph (not a soft break), split at the caret; plain Enter inside a fence still submits |
Coexistence with native behavior
- Enter: keeps DSH's native "submit" and is never intercepted (on list lines and inside fences alike);
- Ctrl/Cmd+Enter: keeps the native "accelerated submit";
- Shift+Enter (no scenario matched): the native soft break (
<br>); - Tab / Shift+Tab (not a list-item line, inside a fence, a range selection, or a modifier chord): fully native (browser focus traversal etc.), never intercepted; likewise yielded while a trigger menu is open; held repeats on a list-item line re-plan like any press (they never fall through to focus traversal);
- While a
/or@trigger menu is open: the Enter family is fully yielded; - IME (e.g. Chinese input): keys during a composition trigger nothing (three-signal guard:
isComposing/keyCode 229/ a 10 ms window aftercompositionend— the same guard the DSH input machine itself uses); - Slash-command claims,
@reference chips, image drag-drop/paste, busy/locked states, and Q&A/approval cards are unaffected; - Coexisting with other composer plugins: capture-phase first-come-first-served — a key already
preventDefault-ed is yielded to; keys this plugin consumes getstopImmediatePropagation.
Architecture
The two halves
A DSH plugin has a host (node) half and a browser half; this plugin is the extreme pure-client shape:
┌─ host half (node) ────────────────────────────────────────────┐
│ index.js a no-op carrier: an importable entry row for │
│ the bundle, so the plugin shows up in │
│ "Settings → Plugins" and is toggleable there │
└────────────────────────────────────────────────────────────────┘
┌─ browser half (web) ──────────────────────────────────────────┐
│ client.js the single-file load artifact (the DSH │
│ loader only accepts single-file bundles), │
│ generated from the 15 src/client/ modules by │
│ scripts/build.mjs and committed; registers │
│ via window.__ModuleLoader__.load │
│ ({id, factory}), with an in-bundle │
│ CommonJS-style module registry │
└────────────────────────────────────────────────────────────────┘
Source layering (src/client/, one-way dependencies top-down)
constants.js constants + line-grammar regexes + CSS class names
(the stylesheet composes its selectors from them — no drift)
editor.js the host-contract seam: the ONLY module touching the
host's underscore internals
(__lexicalEditor/_nodes/_nodeMap/_selection/_compositionKey)
grammar.js the line-grammar substrate: visual lines, fence intervals
+ committed coverage, the shared caret-line resolution,
the selection-covers reveal rule — derived once per read
doc.js the document read: block/leaf flat geometry + caret/selection
mapping (committed/live flavors) + the husk-block query
fence-plan.js pure fence projections and decisions (line/block roles,
soft-line promotion, fence pairs, orphan cleanup,
atomic keys, caret homing)
code-plan.js the pure inline-code kernels + per-block span/delimiter
projections
enter-plan.js pure Shift+Enter arbitration (one decision tree: fences
first, then lists)
list-plan.js pure list planning (the state-driven renumber walk, marker
glyphs, atomic delete/hop/homing, join/detach, shift-down,
re-anchor)
analysis.js one analyzeDraft(texts): the model + every domain projection
edits.js the edit algebra: the ONLY home of every live-node mutation
(flat-range erase / soft-line promotion / paragraph split /
digit rewrite / format shapes)
style-sheet.js the CSS text (composed from the class constants) + the
style-tag lifecycle
present.js the presentation layer: block marks (fence classes / badge)
+ the ONE glyph-mark engine ({at, class, reveal} table)
+ stripDom
gestures.js the key surface: guards + the gesture policy table
(prepare/apply/history steps) + the one claim path
restyle.js the convergent engine: the ordered STAGES table (normalize →
DOM marks → repairs → caret home → code/marker shapes),
one analysis per pass, at most one structural commit
+ cross-pass memory + loop guard; instance state, never
module globals
index.js the composition root: lifecycle + __internals assembly
Five cross-cutting designs
Features are not individually plumbed pipelines — they are rows over one set of orthogonal abstractions:
- One line grammar: visual-line splitting (each paragraph split on
\n; a soft break's\nand a pasted\nare the same thing), fence pairing with committed coverage, and caret-line resolution are derived once ingrammar.js; lists, inline code, fences, and Enter arbitration all consume the same model — they cannot drift apart by construction. - One draft analysis:
analyzeDraft(texts)assembles the fence roles, soft-line promotion, inline-code spans/delimiters, list marker glyphs, and the renumber invariant into a single object — every projection sees the same lines and the same fence coverage. - One edit algebra: plan (pure data, unit-tested) and apply (live-node operations,
edits.jsonly) are strictly separated; neither the gesture policies nor the convergence engine touch nodes directly. - One presentation engine: edit-state visuals come in exactly two shapes — block marks (fence paragraph classes / badge) and glyph marks (a single-char leaf + class + reveal rule); hidden backticks and list-marker styling share one engine and one reveal predicate — adding a glyph kind is a table row, not a pass.
- One table-driven dual driver: the keyboard side is a policy table (one prepare/apply/history per key family), the convergence side is a stage table (one pure plan function per stage); new behavior is a row in a table, not a new pipeline.
Key technical details
The keyboard path (why the document capture phase): DSH's Enter command handler swallows every non-Shift Enter at CRITICAL priority (submit), leaving no seam at the command layer; Lexical dispatches Enter from the root element's own keydown listener. The plugin therefore listens to keydown at the document capture phase — guaranteed to run before Lexical — and only on a matching edit scenario calls preventDefault() + stopImmediatePropagation() to keep out the native soft break, then performs the insertion via editor.update(). Plain Enter never enters this path.
The hidden-glyph trick (zero-advance invisibility): Lexical renders every text node as its own element. The styling write first splits the text around each backtick/marker character (splitText) and flags the single-char leaf unmergeable (Lexical's normalization would merge same-format adjacent leaves back; only this detail bit keeps the isolation); a follow-up DOM scan then classes those leaves via editor.getElementByKey(): the code font + letter-spacing: -1ch cancels the character's advance exactly, and color: transparent hides the ink — real font metrics are kept. display: none is unusable (no box — the browser cannot anchor a caret beside it), and so is font-size: 0 (the caret takes its height from the text node it anchors in — you would get an invisible zero-height caret). The bullet dot renders through an ::after pseudo-element (placed after the zero-advance dash, so a line-head caret renders before the whole • unit).
Commit-only fence activation: only a closed fence renders; an unclosed interval covers only its own marker line in the coverage rule — typing ``` mid-line neither renders the content below into a code block nor turns off list continuation / inline-code pairing on those (plain-text) lines.
Soft-line promotion (fences at any line head): the fence grammar speaks paragraphs, while a soft break is an in-paragraph <br> and a multi-line paste splices a literal \n into a text node. Restyle stage 1 promotes the fence-adjacent soft boundaries to real paragraph breaks (text-projection identical; the draft/send text stays unchanged byte-for-byte), so ``` typed at the head of any line can commit into a block.
The convergence engine: restyle is driven by the editor's update listener — each pass reads the document once, runs the stage table, and commits at most one structural update; the commit re-fires the listener and the next pass finds no diff and stops (writes happen only on difference — idempotent convergence). Self-triggered writes are rate-limited (16 per rolling second) as a loop guard; an in-flight IME composition is yielded to outright. Format/style writes carry the history-merge tag so they don't pollute the undo stack; structural gestures (insert+shift, join/detach+renumber) are discrete undo steps — one Ctrl+Z reverts the whole gesture.
Host contract and safe degradation: lexical is not a module-table shared module, and the plugin must not bundle a second lexical (two copies would split module state and node classes). Every node operation goes through the host editor instance: editor._nodes for the real node classes, editorState._nodeMap/_selection for reads and positioning, and the host nodes' own methods (insertAfter/append/select/splitText/spliceText/setFormat) throughout. These internal access points, like __lexicalEditor, are de-facto contracts concentrated in editor.js; when an upgrade breaks one, the plugin degrades safely to a no-op (one console.warn).
Send fidelity: except for the ordered-list renumbering (which itself rewrites the digits to their literal continuous values — what you see is what gets sent), no line touches text content — the send text = the composer's clipboard projection = per-text-node getTextContent(); format bits and DOM classes never affect serialization.
Compatibility
- DSH
0.1.2-alpha.x(composer = a Lexical plain-text editor +@lexical/plain-text, lexical0.49); - modern desktop browsers (Chrome / Edge / Firefox / Safari);
- coexisting with other composer plugins: capture-phase first-come-first-served (see "Coexistence with native behavior").
Known limits
- List-marker styling and backtick hiding are edit-state visual layers: the characters remain in the draft/copy/send text; a covering selection restores the raw glyphs and widths. The bullet dot's width does not exactly match the original dash (the dot uses the code-font
::after) — in extremely narrow columns the dot may be slightly wider than the original-; an acceptable typographic difference. - Vertical homing has a brief transient: after ↑/↓ lands inside a marker, the eviction happens in the following restyle scan (microtask-scale); in extreme cases one frame of the interior position may be visible; ←/→ arbitration at key time has no such transient.
- A code block's language id cannot be edited directly (the marker line is unreachable): changing the language = one Backspace unwrap (body preserved), then retype the fence.
- An unclosed
```never renders (pasted/draft-restored ones included); Shift+Enter on the marker line (or typing the closing```) commits it into a block. / ````x`` ````-style multi-backtick delimiters are not recognized (naive pairing).- Ordered-list normalization rewrites every non-continuous numbering as it stands: manual gaps and non-1 starts snap back; deeper indents merely suspend the outer group (blank lines, fences, and plain/bullet lines at-or-shallower than the group still break it); groups with more than 9-digit numbers (≥ 1 billion lines) are out of scope.
- The level ladder's subtree boundary is "strictly deeper indent": a blank line, a fence region, or a line at-or-shallower than the item ends the subtree (the same boundary semantics the renumber grouping uses); deep-indented lines after a blank are not the item's children and do not ride the shift.
- The sink cap (v2.9): the parent = the nearest item line above (plain text lines are skipped, not parents; blanks and fence regions end the search); an item may sit at most one level below it (parent indent + 2). An item at the cap — and a list's first item — gets a claimed-but-inert no-op on Tab: no boundless descent, and focus never jumps away mid-list-editing. To go one level deeper, sink the parent (or a sibling above) first. Lifting has no cap (level 0 is its natural floor).
- Unlisting (a top-level lift) splits an ordered group: the plain line left behind makes the same-indent members below restart at
1.per the invariant — a direct consequence of the numbering contract, not a bug. Since v2.8 this gesture replaces the old ordered-item Backspace join-into-the-line-above. - The atom's "unselectable, undeletable" indent is a steady-state keyboard guarantee: a collapsed caret cannot enter the atom interior (arrow hops, stray-caret homing, whole-atom Delete at the line head); drag/Ctrl+A range selections can still cover the atom characters (same select-what-you-see rule that keeps manual digit editing reachable), and native deletion then applies.
- Inline-code content that happens to be a
/name/@nametext-reference token in the lexicon may have its styling overridden by the chip coloring (text content unaffected). - A reference chip (
@xxx) containing line breaks cannot be soft-line-promoted (chips are atomic nodes); fence judgement on such lines degrades to the whole paragraph. - A fully reveal-free inline-code scheme needs chip nodes (tech-doc route B, left for future evolution).
Development notes and conventions
Build and test
node scripts/build.mjs # regenerate client.js from src/client/
node scripts/build.mjs --check # only verify client.js is in sync with the sources
node tests/run-tests.mjs # artifact freshness + pure-logic unit tests + bundle-shape assertions
DSH_CHECKOUT=/path/to/dsh node tests/run-tests.mjs # + the host-contract smoke (grep assertions)
npm run build / npm test are equivalent shortcuts. The manual acceptance checklist lives in tests/e2e-recipe.md (Chinese; drivable with playwright-cli — keyboard events must be real keydowns). Design and evidence document: dsh-composer-markdown-view-tech.md at the repository root (Chinese; requirements, evidence index, rejected alternatives).
Source conventions
- Never edit
client.jsdirectly: it is the artifact generated byscripts/build.mjs, committed to the repo, and its freshness is asserted by the tests; editsrc/client/, rebuild, and commit the regenerated artifact. The DSH loader loads each plugin's./clientexport as a single<script src>(no multi-file plugin support) — the one and only reason a build step exists. - Module convention: every
src/client/*.jsfile is a CommonJS-style module body (receivingmodule, exports, require;require('./xxx')resolves through the in-bundle registry); the build concatenates them verbatim inMODULESorder — no transpilation, no re-indentation. Zero runtime dependencies — no external packages. - Never import/bundle lexical: lexical is not a module-table seed; a second copy would split module state and node classes. All node work goes through the host editor instance (
editor.jsis the only contract seam); when adding host-internal access points, put them ineditor.jsand keep the "broken contract → warn once → no-op" degradation. - Extension is a table row, not a pipeline: a new key gesture → one
{keys, guard, prepare, apply, history}entry in thegestures.jspolicy table; a new convergence stage → one pure plan function inrestyle.js'sSTAGES; a new glyph style → one{at, class, reveal}row inpresent.js's glyph table. No new pipelines. - plan/apply separation: planners must be pure functions of the current draft (no cross-pass memory — the fence-pair memory of the orphan cleanup excepted), exposed for unit testing through
__internalsto the zero-browser runner; live-node mutations live only inedits.js. - Convergence discipline: write only on difference; at most one structural commit per pass; style/caret writes carry
history-merge, structural gestures use discrete undo steps; respect theMAX_WRITES_PER_SECONDloop guard and the IME yield. - Styling reuses DSH theme tokens (
--ds-font-family-code,--dsw-alias-markdown-inline-code,--dsw-alias-markdown-code-block*); selectors are composed from the class-name constants inconstants.js— no hardcoded colors, no CSS/class-name drift.
License
MIT (see LICENSE).