Self-hosting
This guide is for pages that load JavaScript as plain files from their own server: a
server-rendered app (Jinja, ERB, Go templates, PHP) with no bundler of its own, or a site whose
policy rules out a CDN. You build the editor once with esbuild,
copy the output next to your other static files, and load it with one <script type="module">
under a strict Content Security Policy.
If your app already has a bundler (Vite, webpack, Rollup, esbuild), you do not need this page: import the package as in your first editor and your bundler does the same splitting.
1. Install
Section titled “1. Install”In a small build project (it can live inside your app's repository), install the editor, its CodeMirror peers and esbuild:
npm install @openmarkdown-dev/editor @codemirror/state @codemirror/view @codemirror/commands \ @codemirror/language @codemirror/language-data @codemirror/lang-markdown \ @codemirror/search @codemirror/autocomplete @lezer/highlight @lezer/markdownnpm install --save-dev esbuildTo build from a local package file instead of the registry (a tarball made with npm pack,
for example before a release reaches npm), install the file by its path. npm 7 and later install
the peers listed above with it:
npm install ./openmarkdown-dev-editor-0.1.0.tgznpm install --save-dev esbuild2. The entry
Section titled “2. The entry”One module of your own imports the editor and mounts it. It reads the document from a
<textarea> your template fills, copies the document back into it on each change (so an
ordinary form post saves it), and hands CodeMirror the page's style nonce:
import { EditorView } from "@codemirror/view";import { createEditorState, mountEditor } from "@openmarkdown-dev/editor";
// The nonce the server put in this response's CSP header, read from the page.const nonce = document.querySelector('meta[property="csp-nonce"]')?.nonce ?? "";const source = document.getElementById("editor-doc");
mountEditor( document.getElementById("editor"), createEditorState({ doc: source.value, extensions: EditorView.cspNonce.of(nonce), // Render ```mermaid blocks as diagrams. Set false to keep them as code and // never load Mermaid; see "Diagrams and the policy" below. diagrams: true, host: { onChange: (doc) => { source.value = doc; } }, }),);3. The build
Section titled “3. The build”This script is the whole build. Run it with node build.mjs; it writes the editor into
static/editor/:
import { build } from "esbuild";import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";import { join } from "node:path";
const outdir = "static/editor";
const result = await build({ entryPoints: ["src/editor.js"], bundle: true, format: "esm", // Mermaid, Temml and each code language become chunks, fetched only when needed. splitting: true, outdir, entryNames: "[name]", chunkNames: "chunks/[name]-[hash]", target: "es2022", minify: true, // Keep /*! … */ and @license comments in the file they came from. legalComments: "eof", metafile: true, logLevel: "warning",});
// Every package that went into the output, with its license text, in one file.const notices = new Map();for (const input of Object.keys(result.metafile.inputs)) { const dir = /^(.*node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(input)?.[1]; if (!dir || notices.has(dir) || !existsSync(join(dir, "package.json"))) continue; const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); const file = readdirSync(dir).find((f) => /^(licen[cs]e|copying|notice)/i.test(f)); const text = file ? readFileSync(join(dir, file), "utf8") : `License: ${pkg.license}`; notices.set(dir, `${pkg.name}@${pkg.version}\n\n${text.trim()}\n`);}const all = [...notices.values()].sort();writeFileSync(join(outdir, "THIRD_PARTY_LICENSES.txt"), all.join(`\n${"-".repeat(72)}\n\n`));format: "esm"withsplitting: truekeeps the editor's lazyimport()s as separate files. Without splitting, esbuild would inline Mermaid and every language into one large file that every page downloads.legalComments: "eof"keeps the license comments some packages put in their code. Most packages do not, so the script also writesTHIRD_PARTY_LICENSES.txtwith the license file of every package in the output. Serve it, or link it from your app's notices page: the MIT and similar licenses require the notice to travel with the code.
4. What it writes
Section titled “4. What it writes”static/editor/├── editor.js the entry: the editor, CodeMirror and Lezer├── THIRD_PARTY_LICENSES.txt the license of every package in the output└── chunks/ ├── chunk-….js code shared between the entry and lazy chunks ├── mermaid.core-….js Mermaid, loaded on the first diagram ├── flowDiagram-….js one chunk per Mermaid diagram type (and so on) ├── temml-….js Temml, loaded on the first formula ├── dist-….js a CodeMirror language package └── ruby-….js, shell-….js a legacy-mode language, one per languageDeploy every file. The entry and the chunks find each other by relative URL, and which chunks a page needs depends on the documents it opens: a diagram type or a code language no one has used yet still has to be there the day someone uses it. Copy the whole directory, never a selection, and copy it again on every upgrade (the hashes in the chunk names change).
5. The page
Section titled “5. The page”The server makes a fresh nonce for every response, sends it in the CSP header and writes it
into the page. This is the strict policy. It is all the editor, its icons and its formulas
need; with diagrams: false in the entry it is all the page needs:
default-src 'self'; script-src 'self'; style-src 'self' 'nonce-{{ nonce }}'; img-src 'self' https:; object-src 'none'; base-uri 'none'A page that renders diagrams uses this one instead (see diagrams and the policy):
default-src 'self'; script-src 'self'; style-src 'self' 'nonce-{{ nonce }}'; img-src 'self' https:; object-src 'none'; base-uri 'none'; style-src-elem 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <title>Notes</title> <meta property="csp-nonce" nonce="{{ nonce }}" /> <style nonce="{{ nonce }}"> #editor { height: 480px; } </style> <script type="module" src="/static/editor/editor.js"></script> </head> <body> <form method="post"> <div id="editor"></div> <textarea id="editor-doc" name="doc" hidden>{{ doc }}</textarea> <button>Save</button> </form> </body></html>script-src 'self'is enough for scripts: the editor and all its chunks are files from your origin, and it runs no inline script and noeval.style-src 'self' 'nonce-…': CodeMirror and the editor inject their styles as<style>elements, andEditorView.cspNoncestamps the nonce on them. The editor writes nostyleattribute: what a formula needs is applied throughelement.style, which the policy allows. Your own inline<style>needs the same nonce;style="…"attributes in your template are blocked by this policy, so put such rules in a stylesheet.img-src https:lets documents show images from the web. Narrow it, or drop it and pass aresolveImageto the host (see images and untrusted content).- The nonce sits on a
<meta>element'snonceattribute rather than in itscontent, the same convention as Vite's: the browser hides the attribute from selectors, and the script reads it from thenonceproperty. - Your template engine must escape the document it writes into the
<textarea>(Jinja's autoescape does). Serve.jsfiles astext/javascript.
Diagrams and the policy
Section titled “Diagrams and the policy”The editor puts a diagram on the page without inline styles: its <style> gets the nonce and
its style attributes are applied through element.style. Mermaid itself does not. To measure
a diagram, it draws it once in the page, setting style attributes and adding <style>
elements that carry no nonce. Under the strict policy the browser blocks those and reports
each one as a violation (about 36 for a flowchart). The diagram still appears, but its text may
be measured without its font settings.
Pick one:
-
Render diagrams: send the diagram policy above. It is the strict policy plus
style-src-elem 'self' 'unsafe-inline'andstyle-src-attr 'unsafe-inline'. A nonce cannot cover Mermaid's<style>elements, and browsers ignore'unsafe-inline'in a directive that also has a nonce, sostyle-src-elemlists none. Inline styles are then allowed on the page; scripts stay'self'only. -
No diagrams: set
diagrams: falsein the entry and keep the strict policy. A```mermaidblock shows as code, and the page never fetches Mermaid (its chunks are still in the output, unused). -
No diagrams, and no Mermaid in the build: import from the package's diagram-free entry instead, and keep the strict policy. Change one line of the entry, and drop
diagrams(that entry ignores it):import { createEditorState, mountEditor } from "@openmarkdown-dev/editor/no-diagrams";@openmarkdown-dev/editor/no-diagramsis the same editor with the same API, built from the same code, minus the diagram support: nothing it imports reaches Mermaid, so esbuild writes no Mermaid chunk andTHIRD_PARTY_LICENSES.txtdoes not list it. A```mermaidblock shows as code. npm still installs themermaidpackage (it is a dependency of the main entry); it just never reaches your output.
Native WebViews and static hosts
Section titled “Native WebViews and static hosts”A desktop or mobile app that shows the editor in a WebView (WKWebView, WebView2, Android WebView), or a site served as static files, has no server to template a page per response. The build above is the same; four things differ.
-
Serve the files from an origin. Module scripts and their chunk imports do not load from
file://. Serve the output through a custom scheme (WKURLSchemeHandler,WebResourceRequested,WebViewAssetLoader) or a local origin, with.jsastext/javascript. Chunk imports are relative, so they resolve against that origin. -
Wait for the page, not for the load event. A WebView can report the page loaded before the module graph has run, so the first call from native code finds nothing to call. Send a message as the entry's last statement and call into the page only after it arrives:
// last line of entry.js (WKWebView; use your WebView's bridge)window.webkit?.messageHandlers?.editor?.postMessage({ type: "ready" }); -
The nonce needs a server. A nonce only protects a page when it is new for every response; a fixed one written into a static file protects nothing. Without a server, use
style-src 'self' 'unsafe-inline'(what diagrams need anyway), keepscript-src 'self', limitimg-srcto the schemes your images come from, and setconnect-src 'none'if the page needs no network. The strict policy above is for a server that sends a fresh nonce. -
Only what is on screen is in the page. The editor draws the lines in view and a margin around them, not the whole document; this keeps long documents fast. Anything that reads the page's DOM sees only that part: the WebView's own find, copying the rendered HTML, and screen readers and other accessibility tools. Read the text with
editor.getDoc(), and for find use the editor's own panel (Cmd/Ctrl+F, oropenSearchPanel(editor.view)from@codemirror/search), which searches the whole document.
Loaded on demand
Section titled “Loaded on demand”The entry holds what every editor needs. The rest is fetched the first time a document needs it, and never otherwise:
- Mermaid and the code for each diagram type, on the first
mermaidcode block of that type (never, withdiagrams: false; not in the output at all, with theno-diagramsentry); - Temml, on the first
$…$or$$…$$formula; - a code language's grammar, on the first fenced block in that language.
A document with none of these downloads only the entry and the chunks it shares.
Measured with the build above on the 0.1.0 package (minified, before compression):
- the output is about 230 files and 5.3 MB on disk, almost all of it lazy chunks that most pages never request;
- an editor that mounts on a plain document fetches the entry and the chunks it imports, about 720 KB;
- the first diagram adds Mermaid's core and that diagram type, about 760 KB for a flowchart;
- the first formula adds Temml, about 210 KB; a code language adds from a few KB to about 50 KB;
- a document with a diagram, math and a code language fetches about 37 files, 1.7 MB.
Built from the @openmarkdown-dev/editor/no-diagrams entry instead, the output is about 125
files and 1.9 MB on disk: no Mermaid chunks. What a page fetches is the same as above for
documents without a diagram (the entry is the same size; a document with math and a code
language fetches about 9 files, 950 KB). The saving is in what you build and deploy.
Serve the files compressed (gzip or brotli); this JavaScript shrinks to roughly a third. The
chunk names carry content hashes, so chunks can be cached for a long time; editor.js keeps its
name and should be revalidated.