npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/typepad

ESM 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: &quot;…&quot;"> 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 / savePrefs over a PrefsStorage you provide.
  • matchReplacement(before, list) and DEFAULT_REPLACEMENTS: the text replacement lookup without ProseMirror; DocBlock, DocRun, Align: the document model toDocx takes.

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