@emkodev/emkoma
v0.8.1
Published
Common Markdown — one rendering path for SSR, preview, and editor
Readme
emkoma
Common Markdown — one rendering path for SSR, preview, and editor.
Pure TypeScript, zero dependencies. Published as @emkodev/emkoma on npm.
Install
npm install @emkodev/emkomaimport { renderMarkdown } from "@emkodev/emkoma/render";Usage
SSR / string rendering
import { renderMarkdown } from "@emkodev/emkoma/render";
const html = renderMarkdown("# Hello\n\nWorld");
// <h1 id="hello">Hello</h1>\n<p>World</p>Browser preview
<script type="module">
import "@emkodev/emkoma/element/register";
</script>
<emkoma-document>
<pre>
# Hello
This is **bold** and *italic* text.
</pre>
</emkoma-document>Browser editor
<emkoma-document editable>
<pre>
# Editable document
Click any block to edit it.
</pre>
</emkoma-document>Design decisions
- No raw inline HTML. emkoma does not support HTML tags in markdown — no
<b>, no<div>, no HTML entities. Markdown syntax is the only way to format content. Interactive elements (form controls, widgets) enter the document exclusively through fenced code block namespaces (widget:,html:).
Architecture
Block handlers are composable pure functions — no custom elements per block
type. Only <emkoma-document> is a custom element (shadow DOM controller).
Child blocks are plain <div>s inside the shadow root.
markdown string
→ identifyBlocks(markdown) → BlockChunk[]
→ handlers[type].render() → HTML strings
→ joinBlockHandler interface
interface BlockHandler {
render(raw: string, attrs?: Record<string, string>): string;
serialize(raw: string, attrs?: Record<string, string>): string;
identify?: (lines: string[], index: number) => IdentifyResult | null;
}BlockHandler is deliberately DOM-free so ./block can be consumed by SSR and
other non-DOM runtimes. Editing UI is a separate concern:
import { registerEditor } from "@emkodev/emkoma/editor";
registerEditor("my-block", (container, raw, attrs, onCommit, onCancel) => {
// build editing UI inside `container`
});Built-in handlers: paragraph, heading, code-block, hr, blockquote,
list. Widget blocks (widget:*) are auto-resolved on demand.
Custom blocks
import { registerHandler, unregisterHandler } from "@emkodev/emkoma/block";
registerHandler("my-block", {
render(raw, attrs) {
return `<div class="my-block">${raw}</div>`;
},
serialize(raw, attrs) {
return "```my-block\n" + raw + "\n```";
},
});
// Remove when no longer needed
unregisterHandler("my-block");Code block metadata
Fence info strings support key-value pairs after the language:
```typescript filepath=src/greet.ts
export function greet() {}
```Source mode (power users)
When editable, the toolbar includes a Visual/Source toggle. Source mode shows
the raw markdown in a textarea for direct editing.
Exports
| Entry point | What | DOM-free |
| ---------------------------------- | ------------------------------------------------ | -------- |
| @emkodev/emkoma | Aggregate of render + block + inline | Yes |
| @emkodev/emkoma/render | renderMarkdown() — top-level SSR entry | Yes |
| @emkodev/emkoma/block | identifyBlocks(), handlers, registry, TableData | Yes |
| @emkodev/emkoma/inline | renderInline(), escapeHtml(), safeUrl() | Yes |
| @emkodev/emkoma/editor | Block editors, editor registry, inline editing | No |
| @emkodev/emkoma/element | EmkomaDocumentElement class (no side effects) | No |
| @emkodev/emkoma/element/register | customElements.define() side effect | No |
Every DOM-free entry point is verified as such by bun run check:pure, so the
table cannot drift from reality.
Tables
Producers should build a TableData and let emkoma emit the fence, rather than
assembling JSON and delimiters by hand:
import { serializeTable, parseTable, type TableData } from "@emkodev/emkoma";
const data: TableData = { head: ["Name", "Qty"], body: [["Bolt", 4]] };
const fence = serializeTable(data); // ```table\n{ … }\n```
parseTable(fence); // → TableDataHead strings are literal label text — nothing is reserved, so a label may
contain colons anywhere. Column alignment is a parallel align array, omitted
entirely for an all-left table:
serializeTable({
head: ["Name", "Qty"],
align: ["left", "right"],
body: [["Bolt", 4]],
});serializeTable widens the fence past any backtick run in the content, so a
table containing a fenced sample cannot close its own block.
Import maps
dist/ is self-contained — every specifier is relative and carries a .js
extension, with no bare specifiers — so it can be vendored and served directly:
{ "imports": { "@emkodev/emkoma/": "/vendor/emkoma/dist/" } }Build & Test
bun run build # compile to dist/
bun test # run all tests
bun run check # type-check all entry points
bun run fmt # format with Biome
bun run lint # lint with BiomeLicense
MIT
