Documentation · v0.2.0

Everything you need to build.

From one script tag to server-side rendering in PHP and Python. The editor, core and SDKs share the same writer-ir.v1 contract — so the concepts are the same everywhere.

writer-ir.v1 32 fixtures JS/TS · PHP · Python MIT

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) and writer (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”.

Frozen contract. writer-ir.v1 is the reference point. Schema changes pass through the spec and the 32 conformance fixtures first — then the code.
New in v0.2.0. Raw HTML is sanitised at the IR boundary in JS, PHP and Python. @writer/pdf produces real PDF bytes with Greek characters, and the React/Vue/Svelte wrappers are ready. See the changelog.

Packages and installation

PlatformPackageInstallation
JS / TS@writer/core
@writer/editor
npm install @writer/core @writer/editor
PHPwriter/writercomposer require writer/writer
Pythonwriterpip install writer
CDNself-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.

index.html
<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.

One document, many outputs. The same Markdown produces the same IR in JS, PHP and Python. The conformance suite checks it across 32 fixtures × 3 languages.

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.

editor.html
<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

AttributeValuesNote
valueMarkdownTwo-way; read the property value.
modewysiwyg · source · split · preview · readonlyDefault wysiwyg.
themedefault · serif-editorial · technical · compact · magazineTypography theme.
schemelight · darkIf missing, it falls back to prefers-color-scheme.
readonly / disabledbooleanReadonly keeps selection/rendering; disabled is fully inert.
toolbar / statusbarbooleanDefault on when missing. The string "false" hides them.
outlinebooleanDefault off; presence enables it.
media-providerprovider idWhich provider feeds the media manager.
media-endpoint / media-tokenURL / stringWriter Media Protocol provider + bearer token.
locale / directionBCP-47 / ltr · rtlLanguage and direction.

Methods

MethodWhat 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.
Chrome defaults. Since 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

Eventdetail
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.

core.ts
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.

render.ts
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();
MethodReturns
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.

writer-ir.v1
{
  "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 as null/blanks.
  • The optional (id, classes, attrs, callout, checked) are omitted instead of becoming null.
  • 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.

typography.ts
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:

theme.css
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.

css.ts
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.

tables.ts
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.

media.ts
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)

FactoryKindAccessUse
createMemoryProvider()memoryfullTests, demos, offline.
createHttpProvider()httpread-onlyJSON index + HEAD.
createServerProvider()serverfullWriter 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.

media.html
<writer-media-manager
  open
  provider="cdn"
  path="/uploads"
  accept="image/*"
  multiple
  title="Media library"
></writer-media-manager>
media.ts
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.

protocol
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}
media.php
<?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);
media.py
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"])
Credentials. Never send long-lived S3/FTP keys to the browser. Register the provider on the server and give the client only a bearer token or an auth callback.

PDF

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.

LanguageBehaviour
JS / TSPromise<RenderResult>· @writer/pdf renderer, engine print, server or custom. Without one → NO_PDF_ENGINE.
PHPRaw bytes· DompdfEngine when there is dompdf/dompdf, otherwise PrintEngine for print-ready HTML.
PythonRaw %PDF-…%%EOF bytes via ReportLab.
pdf.ts
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.php
$pdf = WriterRender($markdown)->asPDF();

header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="document.pdf"');
echo $pdf;
pdf.py
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.

render.php
<?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();
MethodReturns
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.

render.py
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.

client.ts
const markdown = editor.getMarkdown();

await fetch('/api/render', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ markdown }),
});
server.php
<?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.

snapshot.ts
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

command.ts
writer.commands.register({
  id: 'insert.signature',
  label: 'Insert signature',
  group: 'insert',
  run(ctx) {
    ctx.setSource(ctx.source + '\n\n— Writer');
    return true;
  },
});

Custom plugin

plugin.ts
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.

FeatureJS/TSPHPPython
Parse Markdown → IR✓✓✓
Render IR → HTML✓✓✓
Render IR → Markdown✓✓✓
Render IR → PDF@writer/pdfdompdfReportLab
Typography manager✓✓✓
CSS framework adapter✓✓✓
Table engine✓✓✓
Media: local filesystem✓ (browser)✓✓
Media: FTP / SFTPvia server✓✓
Media: S3 / GCS / Azurevia server✓✓
Media Protocol endpointclient✓✓
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.

FileSize (gzip)UseDownload
writer.min.js≈130 kBEditor + core (script tag).Download
writer.jsunminifiedDebugging.Download
writer.csssmallOptional page-level tokens.Download
writer-core.min.js≈100 kBRender-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:

index.html
<script
  src="/assets/writer/0.2.0/writer.min.js"
  integrity="sha384-…"
  crossorigin="anonymous"
></script>
  • MIME: text/javascript for .js, text/css for .css, image/svg+xml for .svg, application/manifest+json for .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.
Last updated: v0.2.0 · writer-ir.v1 See the demos · Render lab · FAQ