@gege-mn/typepad
v0.3.7
Published
Keyboard layouts, an editing model, lint helpers and editor integrations for traditional Mongolian script (Mongol bichig) — the toolkit behind the gege.mn type pad.
Readme
typepad
Type traditional Mongolian script (Mongol bichig) anywhere. The keyboard layouts, editing model and lint plumbing behind the gege.mn type pad, as a library: drop a bichig keyboard into a textarea, a ProseMirror/TipTap editor, or a vertical editor of your own.
- Layouts by key position. Four layouts (Standard, Latin, Monkhgal, Todo)
matched on
event.code, so the OS input language never matters. Output is standard Unicode: MVS-joined suffixes, fullwidth punctuation, no legacy presentation forms. - An editing model in code points. Caret, selection and undo history indexed the way gege-linter reports diagnostics, so a fix never lands half a surrogate pair off.
- Lint you can see. Issues widened to whole words, because a wash under a zero-width connector is invisible.
Install
pnpm add @gege-mn/typepadESM only. @gege-mn/gege-linter and @gege-mn/mongol-bichig come with it.
prosemirror-model, prosemirror-state and prosemirror-view are optional
peers, needed only for @gege-mn/typepad/prosemirror.
| Import | What | Needs |
|---|---|---|
| @gege-mn/typepad | layouts, key resolution, TextModel, lint helpers, glyph data, paste normalization, prefs | nothing |
| @gege-mn/typepad/dom | attachKeyboard, vertical-lr Geometry, writeClipboard | a DOM |
| @gege-mn/typepad/prosemirror | typepadKeys, typepadLint, fix commands, typepadReplace, bichigSchema | prosemirror-* |
| @gege-mn/typepad/docx | toDocx: a vertical Mongolian Word document | CompressionStream (browsers, Bun, Node ≥ 21) |
| @gege-mn/typepad/svelte | the pad's UI as Svelte 5 components, plus reactive Editor, LintState, TapeView, Prefs | svelte 5 |
A textarea
import { layoutById } from '@gege-mn/typepad';
import { attachKeyboard } from '@gege-mn/typepad/dom';
const detach = attachKeyboard(textarea, { layout: () => layoutById('standard') });Layout keys type bichig at the selection; Backspace, arrows, shortcuts and IME
stay with the element. layout returning null hands everything back to the
OS keyboard. swallowUnmapped: true also drops printables the layout does not
map, so only layout text gets in.
ProseMirror / TipTap
import { layoutById } from '@gege-mn/typepad';
import { typepadKeys, typepadLint, applyLintFix } from '@gege-mn/typepad/prosemirror';
const inBichig = (state) => state.selection.$from.parent.type.name === 'bichig';
const plugins = [
typepadKeys({ layout: () => layoutById('standard'), active: inBichig }),
typepadLint({ nodeName: 'bichig', debounceMs: 300 }),
];
// TipTap: return them from an extension's addProseMirrorPlugins().typepadKeys takes only layout keys inside the active block and dispatches
tr.insertText; Mod-z, Mod-/, Enter, Backspace and arrows reach the host, so
its own undo (prosemirror-history or Yjs) and keymaps keep working.
typepadLint lints each matching textblock after debounceMs of quiet and
maps its decorations through every transaction in between, so typing in a long
block never waits on the linter. Each highlighted word is a span:
<span class="typepad-lint typepad-lint--warning"
data-typepad-severity="warning"
data-typepad-rule="nnbsp-legacy"
data-typepad-code="GL0101"
data-typepad-message="NNBSP (U+202F) suffix connector is legacy…"
data-typepad-issue="nnbsp-legacy:10:11">…</span>.typepad-lint--error { background: rgb(220 38 38 / 0.15); }
.typepad-lint--warning { background: rgb(217 119 6 / 0.16); }
.typepad-lint--info { background: rgb(20 17 13 / 0.055); }For click-to-fix, read data-typepad-issue (space-separated when issues share
a word) and call applyLintFix(view, id), or pass a document position. Fixes
go in as one minimal replace step, re-linted first, so a stale fix is refused
rather than misplaced. fixAllInNode(view, pos) fixes a whole block in one
transaction. Both have command forms, lintFixCommand and fixAllCommand.
lintIssuesAt(state, pos) finds the issues under a position;
refreshLint(view) re-lints immediately, e.g. after toggling showHints.
Messages are English; locale ('mn', 'zh-Hans', 'fr', or a function
read at each re-lint) gives them in another of gege-linter's languages.
Use one copy of prosemirror-view: decorations made by a second copy are
not recognised. With TipTap, add prosemirror-state, prosemirror-view and
prosemirror-model at the versions @tiptap/pm resolves, and check
pnpm why prosemirror-view lists one version.
Text replacements
import { DEFAULT_REPLACEMENTS } from '@gege-mn/typepad';
import { typepadReplace, undoReplacement } from '@gege-mn/typepad/prosemirror';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap } from 'prosemirror-commands';
const plugins = [
typepadKeys({ layout: () => layoutById('standard'), active: inBichig }),
typepadReplace({ active: inBichig, replacements: () => DEFAULT_REPLACEMENTS }),
keymap({ Backspace: undoReplacement }),
keymap(baseKeymap),
];A Replacement is { triggers, text, note? }: when typing completes a
trigger at the start of a word, the trigger becomes text, keeping its marks.
The one default turns the letters typed for ХХК (H H K on the standard, Latin
and Monkhgal layouts, or on the OS Mongolian keyboard) into the abbreviation
written out with nirugu. Typing is text from typepadKeys or the OS keyboard:
a single text insert that leaves the caret right after it. Paste, drop,
undo/redo, remote steps (addToHistory: false) and IME composition in
progress are left alone; mark a programmatic insert with
tr.setMeta(typepadReplaceKey, false) to do the same. Lint fixes carry that
mark already.
undoReplacement is a command: right after a replacement, with nothing typed
or moved since, it puts the typed trigger back. Bind it to Backspace ahead of
baseKeymap. The replacement itself joins the typing's undo step, so Mod-z
takes both. matchReplacement(before, list) in the core is the lookup alone,
for an editor of your own.
A schema for bichig documents
bichigSchema is a small rich-text schema: paragraph and heading (levels
1–3), each with align ('start' | 'center' | 'end' | 'justify'), text,
hard_break, and the marks strong, em, underline, size ({ pt })
and font ({ family }, a family by name: the writer's system draws it,
nothing is embedded). It writes <p>, <h1>–<h3>, <strong>, <em>,
<u>, <span style="font-size: 14pt">,
<span style="font-family: "…""> and <br>; alignment other than start is
style="text-align: …", which in writing-mode: vertical-lr is top, centre
or bottom of the column. It reads the same back, and pasted Word and web HTML
degrades to it: b and font-weight 600 or more, i and italic, u and
underline, font-size in pt or px (as pt), align="center", and h4–h6 as
level 3.
docBlocks(doc) turns a document into plain DocBlock[] (core types DocBlock,
DocRun, Align), the input of toDocx.
Word (.docx)
import { toDocx } from '@gege-mn/typepad/docx';
import { docBlocks } from '@gege-mn/typepad/prosemirror';
const bytes = await toDocx(docBlocks(view.state.doc), {
title: 'Letter',
font: { name: 'Noto Sans Mongolian', data: ttf }, // optional: embeds the font
size: 12, // body pt
page: { width: 210, height: 297, margin: { top: 20, right: 20, bottom: 20, left: 20 } }, // mm
});
const blob = new Blob([bytes], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});The section is set tbLrV, which Word on Windows shows as "Vertical for
Mongolian" (MS-OI29500); Word elsewhere, LibreOffice and Pages show the text
horizontally. Headings use the styles Heading 1–3 (bold, 1.6, 1.35 and 1.15
times the body size); start, center, end and justify are written as
w:jc left, center, right and both, which in a vertical section are top,
centre, bottom and justified. With font.data the TTF is embedded the way Word
does it (obfuscated with a random font key, ECMA-376 Part 1, 17.8.1) and
embedTrueTypeFonts is set; without it, the font is named and the reader's own
copy is used. Defaults: Noto Sans Mongolian, 12 pt, A4 portrait, 20 mm margins.
The subpath has no prosemirror or svelte in it.
Your own editor
The core has no DOM in it. resolveKeydown is the pad's whole keydown:
import { applyCommand, layoutById, resolveKeydown, TextModel } from '@gege-mn/typepad';
import { detectPlatform } from '@gege-mn/typepad/dom';
const model = new TextModel();
const platform = detectPlatform();
el.addEventListener('keydown', (e) => {
const action = resolveKeydown(e, layoutById('standard'), platform);
if (!action) return;
if (action.type === 'command') {
if (action.command.preventDefault) e.preventDefault();
applyCommand(model, action.command); // false: copy, cut, column moves, keyboard chart
} else {
e.preventDefault();
if (action.type === 'insert') model.insert(action.text);
}
});
model.subscribe(() => render());resolveKey is the layout lookup alone ('' for a key the layer leaves
empty: swallow it); resolveCommand is the shortcut table alone. Writing is
vertical-lr: Up/Down move along a column, Left/Right across columns, which is
what Geometry.moveColumn measures. Geometry places a caret, selection and
lint boxes over one untouched text node, since splitting the run would change
the shaping.
TextModel indexes everything in code points; u16Offsets, cpToU16 and
u16ToCp convert for the DOM and ProseMirror.
Svelte
The type pad's UI, as components you compose yourself: a vertical writing
surface, a glyph palette, a keyboard chart, a lint bar, the code-point tape, an
inspector and a settings dialog. Svelte 5 only; the package ships .svelte
source, compiled by your build.
<script lang="ts">
import { type PrefsStorage, prefsKeys } from '@gege-mn/typepad';
import {
Composer, Editor, Inspector, KeyboardChart, LintBar, LintState,
Palette, Prefs, Settings, Tape, TapeSummary, TapeView,
} from '@gege-mn/typepad/svelte';
const storage: PrefsStorage = {
get: (k) => localStorage.getItem(k),
set: (k, v) => localStorage.setItem(k, v),
remove: (k) => localStorage.removeItem(k),
};
const prefs = new Prefs({ storage, keys: prefsKeys('myapp:typepad') }).load();
const editor = new Editor();
const lint = new LintState(editor, prefs);
const tape = new TapeView(editor, lint, prefs);
let composer = $state<ReturnType<typeof Composer>>();
let chart = $state<ReturnType<typeof KeyboardChart>>();
const refocus = () => composer?.focus();
$effect(() => prefs.save());
</script>
<Composer bind:this={composer} {editor} {lint} layout={prefs.layout} size={prefs.size}
convertPaste={prefs.autofix} onkeydown={() => (tape.pinned = false)} />
<LintBar {editor} {lint} {prefs} {refocus} />
<Palette oninsert={(t) => editor.insert(t)} onbackspace={() => editor.deleteBackward()}
bind:showSuffixes={prefs.showSuffixes} {refocus} />
<TapeSummary {editor} {lint} {tape} {prefs} {refocus} />
<Tape {editor} {lint} {tape} {refocus} />
<Inspector {editor} {lint} {refocus} />
<KeyboardChart bind:this={chart} bind:layoutId={prefs.layoutId} onclose={refocus} />pnpm dev in this repo runs the same thing (demo/), with a dark theme toggle.
State
| Class | What |
|---|---|
| Editor | TextModel with reactive reads (text, caret, anchor, sel, history, and every query). Mutations are the model's: pass it to applyCommand, Geometry, tapeRuns. selectDiagnostic(d) puts the caret on an issue. |
| LintState(editor, { showHints }, { rules? }) | all, diags, nErr / nWarn / nInfo, fixables, worstSev, cellSev, ranges (what the composer washes), at(i), next(from, dir). Muting hints never re-lints. |
| TapeView(editor, lint, { tapeScope }) | runs, chunks, shown, range, and pinned: set by a hand-scroll of the tape, cleared by an insert. Clear it on composer keydown too. destroy() stops listening. |
| Prefs({ storage?, keys?, initial? }) | TypepadPrefs as $state fields, plus layout. load(), save(), snapshot(), assign(), reset(). Without storage nothing persists; with it, keys is required (prefsKeys(prefix)). |
The { showHints } and { tapeScope } arguments are any reactive object with
that field, usually the Prefs.
Components
Every component takes messages (see below) and class, and its root carries
typepad typepad-<name> for styling from outside (:global(.typepad-composer .caret)).
refocus is called after a click so focus returns to the writing surface.
| Component | Props | Exports |
|---|---|---|
| Composer | editor, layout, lint?, size (px, 108), font?, convertPaste (true), interceptPaste?(text) → true to consume, oncopy?() → ok (Cmd/Ctrl+C and cut; default copies the selection or everything), onkeydown?(e) (runs first; preventDefault() takes the key), platform? | focus(), refresh() (re-measure and scroll to the caret, e.g. after the host resized it), remeasure(), geometry() |
| Palette | oninsert(text), onbackspace? (shows ⌫), bind:showSuffixes, font?, refocus?, children (extra sections) | |
| KeyboardChart | bind:layoutId, layouts (LAYOUTS), shortcut (true: owns Cmd/Ctrl+K on the window), oninsert? (caps become buttons that type), onclose?, font?, platform?, footer snippet | show(), close(), toggle() |
| LintBar | editor, lint, prefs (showHints, toggled), onfixall?, onstep?(dir), refocus? | |
| TapeSummary | editor, lint, tape, prefs (tapeScope, latin), onstep?(dir), onfollow?, refocus? | |
| Tape | editor, lint, tape, follow (true: scroll to the caret unless pinned), refocus? | scrollCellIntoView(i) |
| Inspector | editor, lint, onfix?(d), refocus?, children({ cell, index }) | |
| Settings | prefs (layoutId, showHints, autofix, font), fonts (FontChoice[]: { name, family, local? }, the first is the default), layouts, sections ('layout' \| 'font' \| 'hints' \| 'autofix', all by default), onclose?, children (extra sections; give them class="typepad-settings-section") | show(), close() |
Callbacks left out do the obvious thing on the editor (fix, fix all, step to
the next issue). matchFont(fonts, font) is the lookup Settings uses.
Theming
Set any of these on an ancestor (dialogs included: they inherit from where
they are placed). Defaults are the gege.mn palette; the translucent ones are
mixed from --typepad-ink and --typepad-accent, so a dark theme needs only
the first few.
| Property | Default | For |
|---|---|---|
| --typepad-paper | #f5f2ea | backgrounds |
| --typepad-ink | #14110d | text |
| --typepad-dim | #736d60 | secondary text |
| --typepad-accent | #6d28d9 | caret, selection, active states |
| --typepad-accent-soft | accent at 10% | selection, active keys |
| --typepad-line / --typepad-line-strong | ink at 10% / 16% | borders |
| --typepad-soft | ink at 4% | panels |
| --typepad-cell | ink at 5% | tape cells, hint chip |
| --typepad-raised | #fff | key hover |
| --typepad-error, -error-bg, -error-line | reds | error text, chips, cells |
| --typepad-warning, -warning-bg, -warning-line | ambers | warnings |
| --typepad-lint-error, -lint-warning, -lint-info | washes | lint under the composer text |
| --typepad-invalid, --typepad-invalid-line | reds | tape cells for unknown or stray code points |
| --typepad-latin | blue at 10% | Latin tape cells |
| --typepad-backdrop, --typepad-shadow | ink | dialogs |
| --typepad-font | sans-serif | bichig glyphs; set a Mongolian font (or pass font) |
| --typepad-ui-font | inherited | UI text (dialogs, kbd) |
| --typepad-composer-mark | block | none hides the composer's corner dot |
The components carry their own minimal reset (border-box, zeroed margins,
inherited button fonts), in @layer typepad, so they need no Tailwind
preflight and any host rule wins over it.
Messages
All copy is English; accessible names are Cyrillic, because bichig is shown
aria-hidden and screen readers read Mongolian in Cyrillic. Override any key
per component (messages={{ vowels: 'Эгшиг' }}) or for a subtree with
setTypepadMessages({...}) during init. DEFAULT_MESSAGES has every key; counts
are functions (codePoints: (n) => …). Lint findings go through diagnostic;
diagnostic: (d) => localize(d, 'mn') (localize from @gege-mn/typepad) shows them in Mongolian.
| Keys | Where |
|---|---|
| composerLabel, placeholder | Composer (the placeholder's first glyph places the empty caret) |
| tapeLabel, tapeEmpty, tapeNothingFlagged, tapeEmptyLine | Tape |
| scopeGroupLabel, scopes, tapeRange, tapeShown, tapeRangeTitle, codePoints, letters, controls, lintCount, nextIssueTitle, copyLatin, latinCopied, copyFailed, copyLatinTitle, follow, followTitle, inspectHint | TapeSummary |
| lintTag, lintClean, errors, warnings, hints, muteHintsTitle, showHintsTitle, fixAll, fixAllTitle, prevIssue, nextIssue | LintBar |
| closeInspector, lintHeading, lintSource, formsHeading, formsNote, fvsHeading, fvsNote, effectHeading, without, withControl, suffixHeading, suffixSource, fix, diagnostic | Inspector (diagnostic also in Tape tooltips) |
| vowels, consonants, extra, controlsHeading, controlsHint, suffixes, suffixesHint, suffixTitle, punctuation, backspace | Palette |
| chartLabel, layoutGroupLabel, layerGroupLabel, base, shift, close, chartHold, chartFlip, chartEsc, chartHover, chartPosition | KeyboardChart |
| settingsLabel, settingsTitle, close, layout, hintsSetting, hintsNote, font, fontWarn, fontLocal, autofix, autofixNote | Settings |
Also in the core
lintReport(text, { showHints, rules }): counts, shown diagnostics, fixables and worst severity;wordRanges,cellSeverities,nextDiagnostic,fixLabel.normalizePaste(text, { convert }): Menksoft and Tungaamal text rewritten as standard Unicode.toCell,classify,latinize,CONTROLS,VOWELS,suffixGroups,KB_ROWS,capParts,GLOSS: the data the pad's palette, keyboard chart and code-point tape are drawn from.TypepadPrefs,loadPrefs/savePrefsover aPrefsStorageyou provide.matchReplacement(before, list)andDEFAULT_REPLACEMENTS: the text replacement lookup without ProseMirror;DocBlock,DocRun,Align: the document modeltoDocxtakes.
Contributing
pnpm, Biome, Vitest; pnpm build && pnpm typecheck && pnpm lint && pnpm test.
Invisible characters (MVS, NNBSP, FVS, ZWJ/ZWNJ, NBSP) are always \uXXXX
escapes in source. Bichig is never retyped: copy it by script and compare code
points.
License
MIT
