Introduction
Writer is three layers sharing one contract:
- Editor — Web Components (
<writer-editor>and companion elements) with WYSIWYG, source, split, preview and readonly modes. - Core —
@writer/core: Markdown → Writer IR, plugins, renderers, typography, tables, media, commands. - SDKs —
writer/writer(PHP) andwriter(Python) that produce the same canonical IR and the same outputs.
The centre of gravity is the Writer IR: a stable, normalized JSON tree. The editor writes Markdown and IR; renderers read IR. There is no “HTML we hope can become Markdown again”.
writer-ir.v1 is the reference point. Schema changes pass through the spec and the 32 conformance fixtures first — then the code.@writer/pdf produces real PDF bytes with Greek characters, and the React/Vue/Svelte wrappers are ready. See the changelog.Packages and installation
| Platform | Package | Installation |
|---|---|---|
| JS / TS | @writer/core@writer/editor | npm install @writer/core @writer/editor |
| PHP | writer/writer | composer require writer/writer |
| Python | writer | pip install writer |
| CDN | self-hosted bundle | <script src="/assets/writer/writer.min.js"></script> |
The bundles are at /assets/writer/. Download the ZIP or individual files from the Download page.
Quickstart
Choose an environment. The result is the same document, whether the browser or the server does the rendering.
<link rel="stylesheet" href="https://writer.gr/assets/writer/writer.css">
<script src="https://writer.gr/assets/writer/writer.min.js"></script>
<writer-editor
id="editor"
theme="serif-editorial"
placeholder="Write…"
></writer-editor>
<script>
const editor = document.querySelector('#editor');
editor.value = '# Hello\n';
editor.addEventListener('wr-change', () => {
console.log(editor.value); // Markdown
});
</script>
writer.css is an optional page-level token sheet. If you omit it, define --wr-* tokens on the host.
npm install @writer/core @writer/editor
import '@writer/editor';
import '@writer/editor/styles.css';
const editor = document.createElement('writer-editor');
editor.setAttribute('theme', 'serif-editorial');
editor.value = '# Hello\n';
document.body.append(editor);
editor.addEventListener('wr-change', () => {
console.log(editor.getMarkdown());
});
composer require writer/writer
<?php
require 'vendor/autoload.php';
$markdown = "# Hello\n\nThis is **Writer**.";
echo WriterRender($markdown)->asHTML();
pip install writer
from writer import WriterRender
markdown = "# Hello\n\nThis is **Writer**."
print(WriterRender(markdown).as_html())
Editor
The editor is a custom element. No React/Vue wrapper is required: value is Markdown in, Markdown out, and editor.writer gives you the whole core.
<writer-editor
id="editor"
mode="wysiwyg"
theme="serif-editorial"
scheme="light"
locale="en"
placeholder="Write…"
toolbar="true"
statusbar="true"
></writer-editor>
Modes: wysiwyg, source, split, preview, readonly. In WYSIWYG, commands apply to the rendered DOM and are written back to Markdown; in source/split/preview they operate on the Markdown source.
Attributes
| Attribute | Values | Note |
|---|---|---|
value | Markdown | Two-way; read the property value. |
mode | wysiwyg · source · split · preview · readonly | Default wysiwyg. |
theme | default · serif-editorial · technical · compact · magazine | Typography theme. |
scheme | light · dark | If missing, it falls back to prefers-color-scheme. |
readonly / disabled | boolean | Readonly keeps selection/rendering; disabled is fully inert. |
toolbar / statusbar | boolean | Default on when missing. The string "false" hides them. |
outline | boolean | Default off; presence enables it. |
media-provider | provider id | Which provider feeds the media manager. |
media-endpoint / media-token | URL / string | Writer Media Protocol provider + bearer token. |
locale / direction | BCP-47 / ltr · rtl | Language and direction. |
Methods
| Method | What it does |
|---|---|
setMarkdown(md) / getMarkdown() | Markdown in / out. |
setHTML(html) / getHTML() | Renders the current document (or sets HTML). |
exec(id, ...args) | Runs any command (e.g. format.bold, table.insert). |
isCommandActive(id) | Pressed state of the current selection. |
canRun(id) | Contextual availability (e.g. format.unlink except link). |
setMode(mode) / setTheme(name) | Change mode / typography. |
openPanel('table' | 'typography' | 'css' | 'media') | Opens an overlay panel in the top layer. |
closePanels() | Closes all panels. |
openMediaManager(opts?) | Media picker; insertion happens at the remembered caret. |
insertMedia(entry, opts?) | Inserts an entry and returns <img> in WYSIWYG. |
registerPlugin(plugin) | Adds a plugin to the current engine. |
destroy() | Cleans up listeners and DOM. |
toolbar/statusbar are on when missing; do not remove the attribute to hide them — set toolbar="false" and call rebuildChrome() if you change it at runtime.Events
| Event | detail |
|---|---|
wr-ready | { writer } |
wr-input | { value, document } — a user change. |
wr-change | { source, document, entry? } — every change, including programmatic ones. |
wr-mode-change | { mode } |
wr-selection | { selection } |
wr-command | { id, result } |
wr-media-select | { entries } — commit a selection. |
wr-upload-progress | { name, loaded, total } |
wr-error | { error } |
All events bubble and are composed, so they reach outside the shadow root.
Core API
createWriter() gives you a stateful engine. WriterRender() is the one-shot, chainable builder for server-side or static rendering.
import { createWriter, parse, stringify } from '@writer/core';
const writer = createWriter({
markdown: '# Hello\n',
gfm: true,
html: 'sanitize',
typography: 'serif-editorial',
});
writer.toHTML(); // HTML string
writer.toIR(); // WriterNode (document)
writer.toJSON(true); // pretty JSON
writer.toMarkdown(); // normalized Markdown
writer.toText(); // plain text
const ir = parse('**hi**'); // Markdown → IR
const md = stringify(ir); // IR → Markdown
createWriter() accepts markdown, document (IR, Markdown wins), gfm, html (sanitize | allow | escape), breaks, linkify, plugins, typography, css, media, renderers, baseUrl.
WriterRender
Chainable builder with the same ergonomics in JS, PHP and Python. It holds no state; every call produces a result.
import { WriterRender } from '@writer/core';
const html = WriterRender(markdown)
.theme('serif-editorial')
.variables({ '--wr-content-max-width': '60rem' })
.asHTML();
const ir = WriterRender(markdown).asIR();
const json = WriterRender(markdown).asJSON({}, true);
const md = WriterRender(markdown).asMarkdown();
const text = WriterRender(markdown).asText();
const pdf = await WriterRender(markdown).asPDF();
| Method | Returns |
|---|---|
asHTML(options?) | string |
asIR(options?) | WriterNode |
asJSON(options?, pretty?) | string |
asMarkdown(options?) / asText(options?) | string |
asPDF(options?) | Promise<RenderResult> |
as(format, options?) | Promise<RenderResult> |
theme(name) / variables(vars) / use(plugin) | chainable RenderBuilder |
asHTMLSync() / asIRSync() / asJSONSync(pretty?) | sync variants (JS) |
One RenderResult contains format, contents (text or base64 for binary), binary, mime, bytes, assets and warnings.
Writer IR
The IR is a clean JSON tree. Positions are not part of the contract; normalize() removes them, merges adjacent text nodes and normalizes whitespace.
{
"type": "document",
"children": [
{
"type": "heading",
"depth": 2,
"children": [{ "type": "text", "value": "Hello" }]
},
{
"type": "paragraph",
"children": [
{ "type": "text", "value": "Use " },
{ "type": "strong", "children": [{ "type": "text", "value": "bold" }] },
{ "type": "text", "value": "." }
]
}
]
}
- Container nodes always have
children(possibly empty). - The “always present” fields (
link.title,image.alt) are emitted even asnull/blanks. - The optional (
id,classes,attrs,callout,checked) are omitted instead of becomingnull. - Round-trip guarantee:
parse(stringify(parse(md)))≡normalize(parse(md)).
Typography
Five built-in themes: default, serif-editorial, technical, compact, magazine. The same setting drives the editor, HTML and print HTML.
import { createWriter, THEMES } from '@writer/core';
const writer = createWriter({ typography: 'serif-editorial' });
writer.typography.setTheme('technical');
const vars = writer.typography.variables();
const css = writer.typography.toCss('article');
const scale = writer.typography.scale();
console.log(THEMES); // default, serif-editorial, technical, compact, magazine
The tokens are CSS custom properties and cross the editor's shadow boundary:
writer-editor {
--wr-font-sans: "Inter", system-ui, sans-serif;
--wr-color-accent: #0b5fd7;
--wr-content-max-width: 62rem;
--wr-radius: 10px;
}
Dark mode: scheme="dark", theme="… dark" or prefers-color-scheme. The dark scheme keeps ownership of text color, so every theme stays readable.
CSS frameworks
The adapter recognises Bootstrap, Tailwind, Bulma, Foundation and UIkit — and knows what a class means.
const matches = writer.css.detect({
classes: ['container', 'row', 'col-md-6']
});
const classes = writer.css.gridClasses(
{ columns: 12, framework: 'bootstrap' },
'bootstrap'
);
const container = writer.css.buildGrid({
columns: 12,
framework: 'bootstrap',
rows: [[
{ content: 'A', span: 6 },
{ content: 'B', span: 6 }
]]
});
const issues = writer.css.lint(writer.doc);
detect() returns matches with confidence/evidence, classify() splits grid/utilities/components, buildGrid() returns container IR node and lint() returns issues with severity and a fix hint.
Tables
TableEngine is complete: create, CSV/TSV, rows/columns, alignment, header, transpose. All mutators are pure — return a new IR tree.
const table = writer.tables.create({
rows: 2,
columns: 3,
header: true
});
const wider = writer.tables.insertColumn(table, 1);
const csv = writer.tables.toCsv(wider);
writer.apply(() => writer.tables.setAlign(wider, 2, 'right'));
const session = writer.tables.session(table.id);
session.focusCell(0, 1);
TableSession is tied to the table's identity and survives re-parsing — so the focused cell stays stable while you type in WYSIWYG.
Media
The editor knows nothing about a filesystem. It knows about providers: small objects with list, stat, read, url and optionally write/upload/delete/folders.
import {
createWriter,
createMemoryProvider,
createServerProvider,
} from '@writer/core';
const writer = createWriter();
writer.media.register(createMemoryProvider({ '/logo.svg': '<svg/>' }, 'demo'));
writer.media.register(createServerProvider({
id: 'cdn',
endpoint: 'https://api.example.com/media',
remote: 'assets',
auth: async () => ({ Authorization: `Bearer ${await getToken()}` }),
}));
writer.media.use('cdn');
Built-in providers (JS)
| Factory | Kind | Access | Use |
|---|---|---|---|
createMemoryProvider() | memory | full | Tests, demos, offline. |
createHttpProvider() | http | read-only | JSON index + HEAD. |
createServerProvider() | server | full | Writer Media Protocol → PHP/Python/Node. |
FTP, SFTP, S3 and cloud providers live in the server SDKs. The browser reaches them through the protocol — never with credentials in the client.
Standalone component
<writer-media-manager> works outside the editor too. Give it a writer (or endpoint) and it can browse, upload, rename, duplicate, delete.
<writer-media-manager
open
provider="cdn"
path="/uploads"
accept="image/*"
multiple
title="Media library"
></writer-media-manager>
const manager = document.querySelector('writer-media-manager');
manager.writer = createWriter({ media: [provider] });
manager.addEventListener('wr-media-select', (event) => {
const [entry] = event.detail.entries;
const markdown = manager.writer.media.toMarkdown(entry, {
alt: entry.name,
});
console.log(markdown);
});
Upload does not mean insert: it refreshes the list, selects the new files and waits for Insert. In single select, choosing an item closes the panel and returns the entry.
Writer Media Protocol
A small REST contract. The PHP Writer\Server\MediaEndpoint and the Python writer.server.media_router implement it, so the same browser code talks to any backend.
GET {base}?action=list&provider={id}&path={path}
GET {base}?action=stat&provider={id}&path={path}
GET {base}?action=url&provider={id}&path={path}
GET {base}?action=read&provider={id}&path={path}
GET {base}?action=search&provider={id}&q={query}
POST {base}?action=upload&provider={id}&path={path}
POST {base}?action=mkdir&provider={id}&path={path}
POST {base}?action=move&provider={id}&from={p}&to={p}
POST {base}?action=copy&provider={id}&from={p}&to={p}
DELETE {base}?action=delete&provider={id}&path={path}
<?php
use Writer\Media\MediaManager;
use Writer\Media\LocalProvider;
use Writer\Server\MediaEndpoint;
$media = new MediaManager();
$media->register(new LocalProvider('/var/www/uploads', 'uploads'));
$endpoint = new MediaEndpoint($media, $responseFactory, [
'tokens' => ['secret-bearer-token'],
]);
$response = $endpoint->handle($request);
from writer.media import MediaManager, LocalProvider
from writer.server import media_router
media = MediaManager()
media.register(LocalProvider("/var/www/uploads", "uploads"))
router = media_router(media, tokens=["secret-bearer-token"])
auth callback.PDF is opt-in and honest. In JS, @writer/pdf provides a first-party renderer with embedded fonts; alternatively an engine print/server/custom. Without a renderer or engine, asPDF() fails with NO_PDF_ENGINE — never returns HTML with a .pdf extension.
| Language | Behaviour |
|---|---|
| JS / TS | Promise<RenderResult>· @writer/pdf renderer, engine print, server or custom. Without one → NO_PDF_ENGINE. |
| PHP | Raw bytes· DompdfEngine when there is dompdf/dompdf, otherwise PrintEngine for print-ready HTML. |
| Python | Raw %PDF-…%%EOF bytes via ReportLab. |
try {
const result = await WriterRender(markdown).asPDF({
page: { size: 'A4' },
metadata: { title: 'Report' },
});
// result.contents: base64 PDF
} catch (error) {
if (error.code === 'NO_PDF_ENGINE') {
// Fallback: standalone print HTML
const print = WriterRender(markdown).asHTML({
standalone: true,
embedCss: true,
});
}
}
$pdf = WriterRender($markdown)->asPDF();
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="document.pdf"');
echo $pdf;
pdf = WriterRender(markdown).as_pdf()
# bytes — start with b"%PDF-"
with open("document.pdf", "wb") as handle:
handle.write(pdf)
On the Demos page you can see the real NO_PDF_ENGINE error — no engine is registered there.
PHP
The PHP SDK exposes the same chainable WriterRender as a global function, together with an object API (Writer::create()) and providers for local/FTP/SFTP/S3.
<?php
require 'vendor/autoload.php';
use Writer\Writer;
$html = WriterRender($markdown)->asHTML();
$ir = WriterRender($markdown)->asIR();
$json = WriterRender($markdown)->asJSON(true);
$md = WriterRender($markdown)->asMarkdown();
$pdf = WriterRender($markdown)->asPDF();
$writer = Writer::create(['gfm' => true, 'theme' => 'technical']);
$result = $writer->render('# Hi', 'html');
echo $result->contents();
| Method | Returns |
|---|---|
asHTML(array $options = []) | string |
asIR(array $options = []) | array |
asJSON(bool $pretty = false) | string |
asMarkdown() / asText() | string |
asPDF(array $options = []) | raw PDF bytes |
as(string $format, array $options = []) | RenderResult |
theme() / variables() / use() / withOptions() | chainable self |
Requirements: PHP ≥ 8.1 with ext-dom. Optional: dompdf/dompdf for real PDFs, ext-ftp, ext-ssh2, league/flysystem.
Python
The Python SDK has snake_case methods and camelCase aliases for parity with the JS contract.
from writer import WriterRender, Writer, render
html = WriterRender("# Hello").as_html()
ir = WriterRender("Hello *world*").as_ir()
json = WriterRender("# Hello").as_json(pretty=True)
md = WriterRender("# Hello").as_markdown()
pdf = WriterRender("# Hello").as_pdf()
result = Writer.render("# Hello", format="html")
same = render("# Hello", "html")
writer = Writer.create({"gfm": True, "theme": "technical"})
print(writer.to_html())
Requirements: Python ≥ 3.9. For PDF, ReportLab. All public methods also have camelCase aliases (e.g. asHTML → as_html).
Recipes
Editor in the browser, render on the server
The editor keeps the Markdown; the server renders it with the same IR. You do not need to send HTML from the client.
const markdown = editor.getMarkdown();
await fetch('/api/render', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ markdown }),
});
<?php
require 'vendor/autoload.php';
$body = json_decode(file_get_contents('php://input'), true);
$markdown = $body['markdown'] ?? '';
header('Content-Type: text/html; charset=utf-8');
echo WriterRender($markdown)->asHTML([
'standalone' => false,
]);
Draft snapshot
toJSONSnapshot() is serialisable and contains version, irVersion, source, document and options.
const snapshot = writer.toJSONSnapshot();
localStorage.setItem('writer:draft', JSON.stringify(snapshot));
const saved = JSON.parse(localStorage.getItem('writer:draft'));
const restored = createWriter({ document: saved.document });
Custom command
writer.commands.register({
id: 'insert.signature',
label: 'Insert signature',
group: 'insert',
run(ctx) {
ctx.setSource(ctx.source + '\n\n— Writer');
return true;
},
});
Custom plugin
import type { WriterPlugin } from '@writer/core';
const upper: WriterPlugin = {
name: 'upper',
render: {
text(_ctx, output) {
return String(output).toUpperCase();
},
},
};
const writer = createWriter({ plugins: [upper] });
Compatibility
The same Markdown produces the same canonical IR in JS, PHP and Python. The 32 fixtures in spec/ run in every SDK.
| Feature | JS/TS | PHP | Python |
|---|---|---|---|
| Parse Markdown → IR | ✓ | ✓ | ✓ |
| Render IR → HTML | ✓ | ✓ | ✓ |
| Render IR → Markdown | ✓ | ✓ | ✓ |
| Render IR → PDF | @writer/pdf | dompdf | ReportLab |
| Typography manager | ✓ | ✓ | ✓ |
| CSS framework adapter | ✓ | ✓ | ✓ |
| Table engine | ✓ | ✓ | ✓ |
| Media: local filesystem | ✓ (browser) | ✓ | ✓ |
| Media: FTP / SFTP | via server | ✓ | ✓ |
| Media: S3 / GCS / Azure | via server | ✓ | ✓ |
| Media Protocol endpoint | client | ✓ | ✓ |
| Interactive editor | ✓ | — | — |
Browsers: evergreen Chromium, Firefox and Safari. The editor needs Custom Elements, Shadow DOM and <dialog>; the bundles are ES2020.
Download
Get the complete v0.2.0 package: browser bundles, render-only core, PHP and Python SDK sources and ready-made examples. MIT licensed.
| File | Size (gzip) | Use | Download |
|---|---|---|---|
writer.min.js | ≈130 kB | Editor + core (script tag). | Download |
writer.js | unminified | Debugging. | Download |
writer.css | small | Optional page-level tokens. | Download |
writer-core.min.js | ≈100 kB | Render-only pages (without the editor). | Download |
Self-hosting the bundles
Serve the files from your own path (e.g. /assets/writer/0.2.0/) with a long cache. If you load them from a third-party domain, use SRI:
<script
src="/assets/writer/0.2.0/writer.min.js"
integrity="sha384-…"
crossorigin="anonymous"
></script>
- MIME:
text/javascriptfor.js,text/cssfor.css,image/svg+xmlfor.svg,application/manifest+jsonfor.webmanifest. - CSP: the bundle does not need
unsafe-eval. If you block inline scripts, move the theme bootstrap to an external file. - The bundles are self-contained and work offline; they need no framework runtime.