Εισαγωγή
Ο Writer είναι τρία layers που μοιράζονται ένα contract:
- Editor — Web Components (
<writer-editor>και companion elements) με WYSIWYG, source, split, preview και readonly modes. - Core —
@writer/core: Markdown → Writer IR, plugins, renderers, τυπογραφία, πίνακες, media, commands. - SDKs —
writer/writer(PHP) καιwriter(Python) που παράγουν το ίδιο canonical IR και τις ίδιες εξόδους.
Το κέντρο βάρους είναι το Writer IR: ένα σταθερό, κανονικοποιημένο JSON δέντρο. Ο editor γράφει Markdown και IR· οι renderers διαβάζουν IR. Δεν υπάρχει “HTML που ελπίζουμε να ξαναγίνει Markdown”.
writer-ir.v1 είναι το σημείο αναφοράς. Αλλαγές στο σχήμα περνούν πρώτα από το spec και τα 32 conformance fixtures — μετά από τον κώδικα.@writer/pdf παράγει πραγματικά PDF bytes με ελληνικούς χαρακτήρες, και τα React/Vue/Svelte wrappers είναι έτοιμα. Δες το changelog.Πακέτα και εγκατάσταση
| Πλατφόρμα | Πακέτο | Εγκατάσταση |
|---|---|---|
| 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> |
Τα bundles είναι στο /assets/writer/. Κατέβασε το ZIP ή μεμονωμένα αρχεία από τη σελίδα Download.
Quickstart
Διάλεξε περιβάλλον. Το αποτέλεσμα είναι το ίδιο document, είτε το render κάνει ο browser είτε ο server.
<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="Γράψε…"
></writer-editor>
<script>
const editor = document.querySelector('#editor');
editor.value = '# Hello\n';
editor.addEventListener('wr-change', () => {
console.log(editor.value); // Markdown
});
</script>
Το writer.css είναι προαιρετικό page-level token sheet. Αν το παραλείψεις, όρισε τα --wr-* tokens στο 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\nΑυτό είναι **Writer**.";
echo WriterRender($markdown)->asHTML();
pip install writer
from writer import WriterRender
markdown = "# Hello\n\nΑυτό είναι **Writer**."
print(WriterRender(markdown).as_html())
Editor
Ο editor είναι ένα custom element. Δεν χρειάζεται React/Vue wrapper: το value είναι Markdown in, Markdown out, και το editor.writer σου δίνει ολόκληρο το core.
<writer-editor
id="editor"
mode="wysiwyg"
theme="serif-editorial"
scheme="light"
locale="el"
placeholder="Γράψε…"
toolbar="true"
statusbar="true"
></writer-editor>
Modes: wysiwyg, source, split, preview, readonly. Σε WYSIWYG τα commands εφαρμόζονται στο rendered DOM και ξαναγράφονται σε Markdown· σε source/split/preview δουλεύουν πάνω στο Markdown source.
Attributes
| Attribute | Τιμές | Σημείωση |
|---|---|---|
value | Markdown | Δύο κατευθύνσεων· διάβασε την property value. |
mode | wysiwyg · source · split · preview · readonly | Default wysiwyg. |
theme | default · serif-editorial · technical · compact · magazine | Typography theme. |
scheme | light · dark | Αν λείπει, ακολουθεί το prefers-color-scheme. |
readonly / disabled | boolean | Readonly κρατά selection/rendering· disabled είναι πλήρως inert. |
toolbar / statusbar | boolean | Default on όταν λείπουν. Το string "false" τα κρύβει. |
outline | boolean | Default off· η παρουσία το ενεργοποιεί. |
media-provider | provider id | Ποιος provider τροφοδοτεί το media manager. |
media-endpoint / media-token | URL / string | Writer Media Protocol provider + bearer token. |
locale / direction | BCP-47 / ltr · rtl | Γλώσσα και κατεύθυνση. |
Methods
| Method | Τι κάνει |
|---|---|
setMarkdown(md) / getMarkdown() | Markdown in / out. |
setHTML(html) / getHTML() | Ρεντάρει το τρέχον document (ή θέτει HTML). |
exec(id, ...args) | Τρέχει οποιοδήποτε command (π.χ. format.bold, table.insert). |
isCommandActive(id) | Pressed-state του τρέχοντος selection. |
canRun(id) | Contextual διαθεσιμότητα (π.χ. format.unlink εκτός link). |
setMode(mode) / setTheme(name) | Αλλαγή mode / τυπογραφίας. |
openPanel('table' | 'typography' | 'css' | 'media') | Ανοίγει overlay panel στο top layer. |
closePanels() | Κλείνει όλα τα panels. |
openMediaManager(opts?) | Media picker· το insert γίνεται στο caret που θυμήθηκε. |
insertMedia(entry, opts?) | Εισάγει entry και επιστρέφει <img> σε WYSIWYG. |
registerPlugin(plugin) | Προσθέτει plugin στο τρέχον engine. |
destroy() | Καθαρίζει listeners και DOM. |
toolbar/statusbar είναι on όταν λείπουν, μην αφαιρείς το attribute για να τα κρύψεις — βάλε toolbar="false" και κάλεσε rebuildChrome() αν το αλλάζεις runtime.Events
| Event | detail |
|---|---|
wr-ready | { writer } |
wr-input | { value, document } — αλλαγή από τον χρήστη. |
wr-change | { source, document, entry? } — κάθε αλλαγή, και programmatic. |
wr-mode-change | { mode } |
wr-selection | { selection } |
wr-command | { id, result } |
wr-media-select | { entries } — commit μιας επιλογής. |
wr-upload-progress | { name, loaded, total } |
wr-error | { error } |
Όλα τα events bubble και είναι composed, οπότε φτάνουν έξω από το shadow root.
Core API
Το createWriter() δίνει ένα stateful engine. Το WriterRender() είναι το one-shot, chainable builder για server-side ή 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() δέχεται markdown, document (IR, κερδίζει το markdown), gfm, html (sanitize | allow | escape), breaks, linkify, plugins, typography, css, media, renderers, baseUrl.
WriterRender
Chainable builder με την ίδια εργονομία σε JS, PHP και Python. Δεν κρατά state· κάθε κλήση παράγει ένα αποτέλεσμα.
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 | Επιστρέφει |
|---|---|
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) |
Ένα RenderResult περιέχει format, contents (text ή base64 για binary), binary, mime, bytes, assets και warnings.
Writer IR
Το IR είναι ένα καθαρό JSON δέντρο. Οι θέσεις (positions) δεν είναι μέρος του contract· το normalize() τις αφαιρεί, ενώνει γειτονικά text nodes και κανονικοποιεί 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 έχουν πάντα
children(ίσως κενό). - Τα “always present” πεδία (
link.title,image.alt) εκπέμπονται ακόμα καιnull/κενά. - Τα προαιρετικά (
id,classes,attrs,callout,checked) παραλείπονται αντί να γίνονταιnull. - Εγγύηση round-trip:
parse(stringify(parse(md)))≡normalize(parse(md)).
Τυπογραφία
Πέντε built-in themes: default, serif-editorial, technical, compact, magazine. Η ίδια ρύθμιση οδηγεί editor, HTML και 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
Τα tokens είναι CSS custom properties και περνούν το shadow boundary του editor:
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" ή prefers-color-scheme. Το dark scheme κρατά την ιδιοκτησία του text color, ώστε κάθε theme να παραμένει ευανάγνωστο.
CSS frameworks
Ο adapter αναγνωρίζει Bootstrap, Tailwind, Bulma, Foundation και UIkit — και ξέρει τι σημαίνει ένα class.
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() επιστρέφει matches με confidence/evidence, classify() χωρίζει grid/utilities/components, buildGrid() επιστρέφει container IR node και lint() επιστρέφει issues με severity και fix hint.
Πίνακες
Το TableEngine είναι πλήρες: δημιουργία, CSV/TSV, rows/columns, alignment, header, transpose. Όλοι οι mutators είναι pure — επιστρέφουν νέο IR δέντρο.
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 είναι δεμένη με το identity του πίνακα και επιβιώνει από re-parsing — έτσι το focused cell μένει σταθερό όσο πληκτρολογείς σε WYSIWYG.
Media
Ο editor δεν ξέρει από filesystem. Ξέρει από providers: μικρά objects με list, stat, read, url και προαιρετικά 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 | Πρόσβαση | Χρήση |
|---|---|---|---|
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 και cloud providers ζουν στα server SDKs. Ο browser τους φτάνει μέσω του protocol — ποτέ με credentials στο client.
Standalone component
Το <writer-media-manager> δουλεύει και έξω από τον editor. Δώσε του έναν writer (ή endpoint) και κάνει 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 δεν κάνει insert: ανανεώνει τη λίστα, επιλέγει τα νέα αρχεία και περιμένει Insert. Σε single select, η επιλογή κλείνει το panel και επιστρέφει το entry.
Writer Media Protocol
Ένα μικρό REST contract. Το PHP Writer\Server\MediaEndpoint και το Python writer.server.media_router το υλοποιούν, οπότε ο ίδιος browser κώδικας μιλά σε οποιοδήποτε 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 είναι opt-in και ειλικρινές. Στο JS το @writer/pdf δίνει first-party renderer με ενσωματωμένες γραμματοσειρές· εναλλακτικά engine print/server/custom. Χωρίς renderer ή engine, το asPDF() αποτυγχάνει με NO_PDF_ENGINE — δεν επιστρέφει HTML με κατάληξη .pdf.
| Γλώσσα | Συμπεριφορά |
|---|---|
| JS / TS | Promise<RenderResult>· @writer/pdf renderer, engine print, server ή custom. Χωρίς ένα από αυτά → NO_PDF_ENGINE. |
| PHP | Raw bytes· DompdfEngine όταν υπάρχει dompdf/dompdf, αλλιώς PrintEngine για print-ready HTML. |
| Python | Raw %PDF-…%%EOF bytes μέσω 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 — ξεκινούν με b"%PDF-"
with open("document.pdf", "wb") as handle:
handle.write(pdf)
Στη σελίδα Demos μπορείς να δεις το πραγματικό NO_PDF_ENGINE σφάλμα — δεν υπάρχει engine καταχωρημένος εκεί.
PHP
Το PHP SDK εκθέτει το ίδιο chainable WriterRender ως global function, μαζί με object API (Writer::create()) και providers για 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 | Επιστρέφει |
|---|---|
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 |
Απαιτήσεις: PHP ≥ 8.1 με ext-dom. Προαιρετικά: dompdf/dompdf για πραγματικά PDF, ext-ftp, ext-ssh2, league/flysystem.
Python
Το Python SDK έχει snake_case μεθόδους και camelCase aliases για parity με το 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())
Απαιτήσεις: Python ≥ 3.9. Για PDF, ReportLab. Όλα τα δημόσια methods έχουν και camelCase alias (π.χ. asHTML → as_html).
Recipes
Editor στον browser, render στον server
Ο editor κρατά το Markdown· ο server το renderάρει με το ίδιο IR. Δεν χρειάζεται να στείλεις HTML από τον 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() είναι serialisable και περιέχει version, irVersion, source, document και 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
Το ίδιο Markdown δίνει το ίδιο canonical IR σε JS, PHP και Python. Τα 32 fixtures του spec/ τρέχουν σε κάθε 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 και Safari. Ο editor χρειάζεται Custom Elements, Shadow DOM και <dialog>· τα bundles είναι ES2020.
Download
Πάρε το πλήρες πακέτο v0.2.0: browser bundles, render-only core, PHP και Python SDK sources και έτοιμα παραδείγματα. Άδεια MIT.
| File | Μέγεθος (gzip) | Χρήση | Λήψη |
|---|---|---|---|
writer.min.js | ≈130 kB | Editor + core (script tag). | Download |
writer.js | unminified | Debugging. | Download |
writer.css | μικρό | Προαιρετικά page-level tokens. | Download |
writer-core.min.js | ≈100 kB | Render-only σελίδες (χωρίς editor). | Download |
Self-hosting των bundles
Σέρβιρε τα αρχεία από δικό σου path (π.χ. /assets/writer/0.2.0/) με μακρύ cache. Αν τα φορτώνεις από τρίτο domain, χρησιμοποίησε SRI:
<script
src="/assets/writer/0.2.0/writer.min.js"
integrity="sha384-…"
crossorigin="anonymous"
></script>
- MIME:
text/javascriptγια.js,text/cssγια.css,image/svg+xmlγια.svg,application/manifest+jsonγια.webmanifest. - CSP: το bundle δεν χρειάζεται
unsafe-eval. Αν κόβεις inline scripts, βάλε το theme bootstrap σε εξωτερικό αρχείο. - Τα bundles είναι self-contained και δουλεύουν offline· δεν χρειάζονται framework runtime.