0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Type markdown on the left and a parser written from scratch inside this one file renders it on the right — headings, fenced code, aligned tables, task lists, nested quotes and links, with no library and no network. It is deliberately a safe renderer: every character is escaped, so raw HTML shows up as text, a javascript: link comes out as plain words, and an image becomes a label rather than a request. Select a phrase and press Ctrl+B, watch the word count move, then open the HTML tab to read exactly the markup the parser produced.
Source — the code you see is the code that just ran 1529 lines 61.2 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Markdown Split Editor — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* --- local surfaces: a recessed fill for code, table heads and the editor.
color-scheme rides along with the tokens so the browser's own parts —
the textarea's scrollbar above all — turn dark with the page.
--accent-ink / --run-ink are the *text* shades used on top of the soft
tints: --accent on --accent-soft is only 4.2:1 in light, which is under
AA for the 10-12px labels that use it, so light gets a darker ink and
dark (already ~9:1) keeps the token as-is. --- */
:root { --fill:#FAFAF9; --fill-2:#F1F0EB; --shadow:0 1px 2px rgba(20,22,26,.05); color-scheme: light;
--accent-ink:#9A4400; --run-ink:#136B2E; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --fill:#171A20; --fill-2:#22262E; --shadow:0 1px 2px rgba(0,0,0,.3);
color-scheme: dark; --accent-ink:var(--accent); --run-ink:var(--run); }
}
:root[data-theme="dark"] { --fill:#171A20; --fill-2:#22262E; --shadow:0 1px 2px rgba(0,0,0,.3);
color-scheme: dark; --accent-ink:var(--accent); --run-ink:var(--run); }
/* --- source colours. Colour ONLY: the overlay must keep the exact text
metrics of the textarea underneath it, so nothing here may change
weight, style, size or spacing. --- */
:root { --t-mk:#B24A00; --t-head:#8A3F00; --t-code:#0E6E6B; --t-url:#2B5CA8;
--t-meta:#7A6A2F; --t-quote:#5A6068; --sel:rgba(192,86,0,.22); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --t-mk:#FFB454; --t-head:#FFCE85; --t-code:#5BC8BE; --t-url:#8FB6FF;
--t-meta:#C9B072; --t-quote:#9AA0A8; --sel:rgba(255,180,84,.26); }
}
:root[data-theme="dark"] { --t-mk:#FFB454; --t-head:#FFCE85; --t-code:#5BC8BE; --t-url:#8FB6FF;
--t-meta:#C9B072; --t-quote:#9AA0A8; --sel:rgba(255,180,84,.26); }
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0;overflow-x:hidden}
body{-webkit-text-size-adjust:100%}
[hidden]{display:none !important}
.wrap{max-width:1180px;margin:0 auto;padding:18px 14px 26px}
.sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
h1{font-size:19px;margin:0;letter-spacing:-.01em;font-weight:650}
.head{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px}
.lede{margin:6px 0 14px;color:var(--muted);font-size:13.5px;line-height:1.55;max-width:74ch}
.badge{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
color:var(--accent-ink);background:var(--accent-soft);border:1px solid var(--accent);padding:2px 7px;border-radius:999px}
.mono{font-family:var(--font-mono)}
/* ---------- controls ---------- */
.btn{font-family:var(--font-sans);font-size:12.5px;color:var(--ink);background:var(--surface);
border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;line-height:1.15;
transition:border-color .12s ease, background .12s ease, color .12s ease}
.btn:hover:not(:disabled){border-color:var(--accent)}
.btn[aria-pressed="true"]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent-ink);font-weight:600}
.btn:disabled{opacity:.42;cursor:default}
.btn--mono{font-family:var(--font-mono);font-size:11.5px;padding:6px 9px}
.btn--ok{background:var(--run-soft);border-color:var(--run);color:var(--run-ink);font-weight:600}
.btn--bad{background:var(--surface);border-color:var(--danger);color:var(--danger);font-weight:600}
.btn--fmt{font-family:var(--font-mono);font-size:12px;padding:5px 9px;min-width:32px}
.btn--fmt.b{font-family:var(--font-sans);font-weight:800}
.btn--fmt.i{font-family:var(--font-sans);font-style:italic;font-weight:600}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
.seg .btn{border:0;border-radius:0;padding:6px 11px}
.seg .btn + .btn{border-left:1px solid var(--line)}
.bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 0 10px}
.bar--tools{justify-content:space-between;gap:10px}
.grp{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.lbl{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);margin-right:2px}
.sep{width:1px;align-self:stretch;min-height:22px;background:var(--line)}
.opt{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted);cursor:pointer;user-select:none;
min-height:26px;padding:2px 2px;border-radius:6px}
.opt input{accent-color:var(--accent);margin:0;width:15px;height:15px;cursor:pointer}
.opt:hover{color:var(--ink)}
/* a dimmed control must not still invite a click */
.opt:has(input:disabled){cursor:default}
.opt:has(input:disabled):hover{color:var(--muted)}
.opt input:disabled{cursor:default}
/* the buttons carry their own :disabled dimming — fading the whole group on
top of that would stack the two and leave it barely legible */
.grp[aria-disabled="true"] .lbl{opacity:.6}
/* ---------- panes ---------- */
.panes{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
.panes[data-view="write"],.panes[data-view="preview"],.panes[data-view="html"]{grid-template-columns:1fr}
.panes[data-view="write"] .card--preview,.panes[data-view="write"] .card--html{display:none}
.panes[data-view="preview"] .card--edit,.panes[data-view="preview"] .card--html{display:none}
.panes[data-view="html"] .card--edit,.panes[data-view="html"] .card--preview{display:none}
.panes[data-view="split"] .card--html{display:none}
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;
display:flex;flex-direction:column;box-shadow:var(--shadow);min-width:0}
.card > header{display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:7px 10px;border-bottom:1px solid var(--line);background:var(--fill);flex:0 0 auto}
.tag{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}
.tag b{color:var(--ink);font-weight:600}
.chip{font-family:var(--font-mono);font-size:11px;color:var(--muted);background:var(--fill-2);
border:1px solid var(--line);border-radius:999px;padding:2px 8px;white-space:nowrap}
.chip b{color:var(--ink);font-weight:600}
.body{position:relative;height:clamp(320px,58vh,620px);overflow:hidden}
.edtext{font-family:var(--font-mono);font-size:13.5px;line-height:1.62;padding:14px 16px;
white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;tab-size:4;letter-spacing:0;
border:0;margin:0;width:100%}
textarea.ed{position:absolute;inset:0;height:100%;resize:none;background:transparent;color:var(--ink);
overflow:auto;overflow-y:scroll;scrollbar-gutter:stable;display:block;outline-offset:-2px;caret-color:var(--accent)}
textarea.ed:focus-visible{outline:2px solid var(--accent);border-radius:0}
.mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;height:auto;
overflow:hidden;color:transparent}
.mline{min-height:1px}
/* When the overlay is proved to line up (see tryEnableHl), the textarea's own
glyphs go transparent and the coloured copy behind it shows through. The
caret and a translucent selection stay visible, so it still types like a
plain textarea. */
.card--edit.hl .mirror{visibility:visible;z-index:0;color:var(--ink)}
.card--edit.hl textarea.ed{color:transparent}
.card--edit.hl textarea.ed::selection{background:var(--sel);color:transparent}
.mirror .t-mk{color:var(--t-mk)}
.mirror .t-head{color:var(--t-head)}
.mirror .t-code{color:var(--t-code)}
.mirror .t-url{color:var(--t-url)}
.mirror .t-meta{color:var(--t-meta)}
.mirror .t-quote{color:var(--t-quote)}
.mirror .t-esc{color:var(--muted)}
@media (forced-colors: active){
.card--edit.hl .mirror{visibility:hidden}
.card--edit.hl textarea.ed{color:CanvasText}
}
/* The scroll panes fill .body exactly, and .body clips its overflow — so an
outline drawn OUTSIDE the pane would be invisible. Draw it inside instead,
otherwise these two keyboard-focusable regions have no focus ring at all. */
.pane{position:absolute;inset:0;overflow:auto}
.pane:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;border-radius:0}
/* ---------- rendered markdown ---------- */
.prose{position:relative;padding:16px 18px 44px;font-size:14.6px;line-height:1.65;color:var(--ink)}
.prose > *:first-child{margin-top:0}
.prose h1,.prose h2,.prose h3,.prose h4,.prose h5,.prose h6{
line-height:1.25;margin:1.5em 0 .5em;font-weight:650;letter-spacing:-.01em}
.prose h1{font-size:1.55em;padding-bottom:.25em;border-bottom:1px solid var(--line)}
.prose h2{font-size:1.28em}
.prose h3{font-size:1.1em}
.prose h4,.prose h5,.prose h6{font-size:1em}
.prose h5,.prose h6{color:var(--muted)}
.prose p{margin:0 0 .95em}
.prose ul,.prose ol{margin:0 0 .95em;padding-left:1.5em}
.prose li{margin:.22em 0}
.prose li > ul,.prose li > ol{margin:.22em 0}
.prose a{color:var(--accent);text-decoration:underline;text-underline-offset:2px;text-decoration-thickness:1px}
.prose a:hover{text-decoration-thickness:2px}
.prose strong{font-weight:680}
.prose hr{border:0;border-top:1px solid var(--line);margin:1.6em 0}
.prose code{font-family:var(--font-mono);font-size:.86em;background:var(--accent-soft);color:var(--accent-ink);
border:1px solid var(--line);border-radius:5px;padding:.08em .34em}
.prose pre{position:relative;background:var(--fill);border:1px solid var(--line);border-radius:8px;
padding:12px 14px;overflow-x:auto;margin:0 0 1em}
.prose pre code{font-family:var(--font-mono);font-size:12.6px;line-height:1.55;background:none;border:0;
padding:0;color:var(--ink);white-space:pre}
.prose pre[data-lang]::after{content:attr(data-lang);position:absolute;top:6px;right:9px;
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
.prose blockquote{margin:0 0 1em;padding:.1em 0 .1em 14px;border-left:3px solid var(--accent);color:var(--muted)}
.prose blockquote > *:last-child{margin-bottom:0}
.prose blockquote strong,.prose blockquote a{color:var(--ink)}
.prose blockquote blockquote{border-left-color:var(--line);margin-top:.6em}
.tablewrap{overflow-x:auto;margin:0 0 1em;border:1px solid var(--line);border-radius:8px}
.prose table{border-collapse:collapse;width:100%;font-size:13.4px}
.prose th,.prose td{border-bottom:1px solid var(--line);padding:7px 11px;text-align:left;vertical-align:top}
.prose th{background:var(--fill);font-family:var(--font-mono);font-size:11.5px;letter-spacing:.04em;
text-transform:uppercase;color:var(--muted);font-weight:600;white-space:nowrap}
.prose tr:last-child td{border-bottom:0}
.prose td + td,.prose th + th{border-left:1px solid var(--line)}
.prose ul.tasks{list-style:none;padding-left:.2em}
.prose li.task{display:flex;align-items:flex-start;gap:.55em}
.prose li.task input{margin:.32em 0 0;accent-color:var(--run);width:13px;height:13px;flex:0 0 auto}
.prose li.task.done{color:var(--muted)}
.prose li.task.done .tasktext{text-decoration:line-through}
.prose del{color:var(--muted)}
.imgref{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:11.5px;
color:var(--muted);border:1px dashed var(--line);border-radius:6px;padding:1px 7px;vertical-align:baseline}
.imgref::before{content:"img";color:var(--accent-ink);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase}
.blocked{color:var(--danger);text-decoration:underline dashed 1px;text-underline-offset:2px;cursor:help}
.emptyish{color:var(--muted);font-size:13.5px;padding:6px 0}
.htmlsrc{margin:0;padding:14px 16px;font-family:var(--font-mono);font-size:12.4px;line-height:1.6;
white-space:pre;color:var(--ink)}
.htmlsrc.empty{color:var(--muted);white-space:pre-wrap}
/* ---------- stats strip ---------- */
.stats{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin:11px 0 0}
.stat{font-family:var(--font-mono);font-size:11.5px;color:var(--muted);background:var(--surface);
border:1px solid var(--line);border-radius:999px;padding:3px 10px;white-space:nowrap}
.stat b{color:var(--ink);font-weight:600;font-variant-numeric:tabular-nums}
.stat.big b{color:var(--accent-ink)}
.hint{margin:14px 0 0;color:var(--muted);font-size:12.5px;line-height:1.6;max-width:80ch}
.hint kbd{font-family:var(--font-mono);font-size:11px;border:1px solid var(--line);border-bottom-width:2px;
border-radius:5px;padding:1px 5px;background:var(--surface);color:var(--ink)}
.hint .note{color:var(--muted);opacity:.85}
footer{margin:22px 0 0;padding-top:12px;border-top:1px solid var(--line);
font-family:var(--font-mono);font-size:11px;color:var(--muted);letter-spacing:.06em}
@media (max-width: 860px){
.panes[data-view="split"]{grid-template-columns:1fr}
.body{height:clamp(280px,44vh,420px)}
.bar--tools{flex-direction:column;align-items:stretch}
.bar--tools .grp{justify-content:flex-start}
}
@media (max-width: 420px){
.wrap{padding:14px 10px 22px}
h1{font-size:17px}
.sep{display:none}
.lbl{display:none}
.btn{padding:6px 8px;font-size:12px}
.seg .btn{padding:6px 9px}
}
@media (prefers-reduced-motion: reduce){
*{animation-duration:.001ms !important;animation-iteration-count:1 !important;
transition-duration:.001ms !important;scroll-behavior:auto !important}
}
</style>
</head>
<body>
<main class="wrap">
<div class="head">
<h1>Markdown Split Editor</h1>
<span class="badge">live · no libraries</span>
</div>
<p class="lede">
Write markdown on the left, see it rendered on the right — by a small parser written
from scratch in this page. Nothing is fetched, nothing is executed: raw HTML is printed
as text and unsafe links come out as plain words. Type anywhere and every count updates live.
</p>
<div class="bar">
<span class="lbl">document</span>
<button class="btn" type="button" data-doc="tour" aria-pressed="true">Feature tour</button>
<button class="btn" type="button" data-doc="notes" aria-pressed="false">Team notes</button>
<button class="btn" type="button" data-doc="readme" aria-pressed="false">Project README</button>
<span class="sep" aria-hidden="true"></span>
<button class="btn btn--mono" type="button" id="reset" title="Reload the current document">↺ reset</button>
</div>
<div class="bar bar--tools">
<div class="grp" role="group" aria-label="Formatting" id="fmtGrp">
<span class="lbl">format</span>
<button class="btn btn--fmt b" type="button" data-fmt="bold" data-tip="Bold (Ctrl+B)" title="Bold (Ctrl+B)" aria-label="Bold">B</button>
<button class="btn btn--fmt i" type="button" data-fmt="italic" data-tip="Italic (Ctrl+I)" title="Italic (Ctrl+I)" aria-label="Italic">I</button>
<button class="btn btn--fmt" type="button" data-fmt="code" data-tip="Inline code" title="Inline code" aria-label="Inline code"></></button>
<button class="btn btn--fmt" type="button" data-fmt="link" data-tip="Link (Ctrl+K)" title="Link (Ctrl+K)" aria-label="Insert link">link</button>
<button class="btn btn--fmt" type="button" data-fmt="head" data-tip="Toggle heading on these lines" title="Toggle heading on these lines" aria-label="Heading">H2</button>
<button class="btn btn--fmt" type="button" data-fmt="list" data-tip="Toggle bullet list" title="Toggle bullet list" aria-label="Bullet list">• list</button>
<button class="btn btn--fmt" type="button" data-fmt="quote" data-tip="Toggle quote" title="Toggle quote" aria-label="Block quote">> quote</button>
</div>
<div class="grp">
<div class="seg" role="group" aria-label="What to show">
<button class="btn" type="button" data-view="split" aria-pressed="true">Split</button>
<button class="btn" type="button" data-view="write" aria-pressed="false">Write</button>
<button class="btn" type="button" data-view="preview" aria-pressed="false">Preview</button>
<button class="btn" type="button" data-view="html" aria-pressed="false">HTML</button>
</div>
<label class="opt" id="syncWrap"><input type="checkbox" id="optSync" checked> sync scroll</label>
<button class="btn btn--mono" type="button" id="copy">copy HTML</button>
</div>
</div>
<div class="panes" id="panes" data-view="split">
<div class="card card--edit">
<header>
<span class="tag"><b>markdown</b> · source</span>
<span class="chip" id="chipLines">0 lines</span>
</header>
<div class="body">
<label class="sr" for="ed">Markdown source</label>
<div class="mirror edtext" id="mirror" aria-hidden="true"></div>
<textarea class="ed edtext" id="ed" spellcheck="false" autocapitalize="off" autocorrect="off"></textarea>
</div>
</div>
<div class="card card--preview">
<header>
<span class="tag"><b>preview</b> · rendered</span>
<span class="chip" id="chipBlocks">0 blocks</span>
</header>
<div class="body">
<div class="pane" id="pane" tabindex="0" role="region" aria-label="Rendered preview">
<div class="prose" id="prose"></div>
</div>
</div>
</div>
<div class="card card--html">
<header>
<span class="tag"><b>html</b> · generated</span>
<span class="chip" id="chipBytes">0 chars</span>
</header>
<div class="body">
<div class="pane" id="htmlPane" tabindex="0" role="region" aria-label="Generated HTML source">
<pre class="htmlsrc" id="htmlOut"></pre>
</div>
</div>
</div>
</div>
<div class="stats" id="stats">
<span class="stat big">words <b id="sWords">0</b></span>
<span class="stat">characters <b id="sChars">0</b></span>
<span class="stat">lines <b id="sLines">0</b></span>
<span class="stat">reading time <b id="sRead">0 min</b></span>
<span class="stat">headings <b id="sHead">0</b></span>
<span class="stat">links <b id="sLinks">0</b></span>
<span class="stat">code blocks <b id="sCode">0</b></span>
<span class="stat">parsed in <b id="sTime">0.00 ms</b></span>
</div>
<p class="hint">
<kbd>Ctrl</kbd>+<kbd>B</kbd> bold · <kbd>Ctrl</kbd>+<kbd>I</kbd> italic ·
<kbd>Ctrl</kbd>+<kbd>K</kbd> link — all with undo intact. The colour in the source pane is a
second pass of the same parser, painted behind the text box, so typing still behaves exactly
like a plain one. Both panes are focusable, so arrows and <kbd>PgDn</kbd> scroll them, and
they stay lined up by source line, not by percentage. <span class="note">Words are counted from the rendered text; images render
as a placeholder chip on purpose — this page makes zero network requests.</span>
</p>
<p class="sr" role="status" aria-live="polite" id="status"></p>
<footer>demo · sloprun.dev</footer>
</main>
<script type="text/markdown" id="doc-tour">
# Markdown, live
Everything on the right is drawn by a small parser written for this page — no
libraries, no network. Edit anything on the left and the preview keeps up.
## The basics
Text can be **bold**, *italic*, ***both at once***, ~~crossed out~~, or `inline
code`. A backslash escapes a character: \*this stays literal\*, and
`snake_case_names` survive intact.
Links look like [this one](https://example.com "hover me"). Two spaces at the end
of a line
force a line break.
## Lists nest
- Groceries
- oats
- two lemons
- Chores
1. water the plants
2. fix the wobbly chair
Task lists work too:
- [x] write the parser
- [x] count the words
- [ ] pick a nicer font
## Code keeps its shape
```js
function words(text) {
return text.split(/\s+/).filter(Boolean).length;
}
```
## Tables line up
| Feature | Supported | Notes |
| -------- | :-------: | ------------------------------- |
| Headings | yes | `#` through `######` |
| Tables | yes | the `:---:` row sets alignment |
| Images | no | this page never loads a file |
## Quotes hold anything
> A quote can carry **bold text**, `code`, a list:
>
> - one thing
> - then another
>
> > and even a second quote inside it.
## Nothing here runs
Raw HTML is printed, never executed: <img src=x onerror=alert(1)> and <b>this
stays as plain text</b>. A link pointing somewhere unsafe —
[press me](javascript:alert(1)) — is stripped back to words. An image becomes a
label, not a request: 
---
Word and character counts update as you type, the two panes scroll together by
source line, and **copy HTML** hands you exactly the markup you see.
</script>
<script type="text/markdown" id="doc-notes">
# Site refresh — notes, 12 May
**Here:** Ada, Malik, Jo, Priya
**Next check-in:** Tuesday, 10:00
## Decisions
1. The homepage stays one column on phones.
2. New type scale ships first, colours follow next week.
3. Nobody adds a carousel. Ever.
> "If a visitor has to ask what the page is for, the page has already failed."
> — Jo, for the third time
## Who has what
| Person | Task | Due | Done |
| ------ | ---------------- | --- | :--: |
| Ada | Type scale | Fri | yes |
| Malik | Rewrite the nav | Mon | no |
| Jo | Copy for the top | Wed | no |
| Priya | Photo cleanup | Thu | yes |
## Still open
- [x] Pick the body font — *Charter* won
- [ ] Do the old blog links stay alive?
- [ ] Who writes the release note?
Shout in `#site-refresh` if any of that is wrong. Full plan lives
[here](https://example.com/plan).
</script>
<script type="text/markdown" id="doc-readme">
# kettle
A forty-line timer that tells you when the water is ready. No dependencies,
about **1 kB** minified.
## Install
```bash
npm install kettle
```
## Use it
```js
import { kettle } from "kettle";
const brew = kettle({ minutes: 3, name: "green tea" });
brew.on("done", () => console.log("ready"));
brew.start();
```
## Options
| Option | Type | Default | What it does |
| --------- | ------- | ------: | -------------------------------- |
| `minutes` | number | 3 | How long to wait |
| `name` | string | `"tea"` | Shows up in the finished message |
| `loud` | boolean | false | Repeats until you acknowledge it |
## Notes
> Timers drift when the tab goes to sleep. `kettle` re-reads the clock on wake,
> so a three-minute brew stays three minutes.
- [x] Works in every current browser
- [x] Works in Node 18 and up
- [ ] A friendlier sound
Longer write-up in the [docs](https://example.com/kettle).
</script>
<script>
(function () {
'use strict';
/* ---------- platform theme bridge (also works standalone via prefers-color-scheme) ---------- */
window.addEventListener('message', function (e) {
var d = e.data;
if (d && d.type === 'sloprun:theme' && (d.theme === 'light' || d.theme === 'dark')) {
document.documentElement.setAttribute('data-theme', d.theme);
}
});
/* =======================================================================
A small markdown renderer. Two passes: block structure line by line,
then inline spans inside each block. Every piece of author text goes
through esc() before it reaches the page, so raw HTML is shown and
never run, and link addresses are checked against a short allow-list
of schemes.
======================================================================= */
var C = { heads: 0, links: 0, code: 0, ids: null };
var BR = String.fromCharCode(1); /* private hard-break marker */
var CTRL = new RegExp('[\\u0000-\\u001F\\u007F]', 'g');
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>'
: c === '"' ? '"' : ''';
});
}
/* Only these addresses become real links. javascript:, data:, vbscript:
and friends fall through to null and get rendered as plain words. */
function safeUrl(u) {
var t = String(u).trim().replace(CTRL, '');
if (!t) return null;
if (/^(https?:\/\/|mailto:|#)/i.test(t)) return t;
if (/^\/\//.test(t)) return null; /* protocol-relative: off-site, not ours */
/* Scheme sniffing ignores whitespace, so "java script:" cannot slip past
by hiding a space (or any other blank) inside the scheme. */
if (/^[a-z][a-z0-9+.\-]*:/i.test(t.replace(/\s+/g, ''))) return null;
return t; /* relative path, no scheme */
}
function slug(text) {
var base = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'section';
var id = base, k = 2;
while (C.ids[id]) { id = base + '-' + k; k++; }
C.ids[id] = 1;
return id;
}
/* ---------- inline ---------- */
function codeSpan(s, i) {
var n = 0;
while (s.charAt(i + n) === '`') n++;
var fence = s.substr(i, n), j = i + n;
while (j < s.length) {
var k = s.indexOf(fence, j);
if (k < 0) return null;
var m = 0;
while (s.charAt(k + m) === '`') m++;
if (m === n) {
var body = s.slice(i + n, k).replace(/\n/g, ' ');
if (body.length > 2 && body.charAt(0) === ' ' && body.charAt(body.length - 1) === ' ' && /[^ ]/.test(body)) {
body = body.slice(1, -1);
}
return { html: '<code>' + esc(body) + '</code>', end: k + n };
}
j = k + m;
}
return null;
}
function findCloser(s, from, c, need) {
var j = from;
while (j < s.length) {
var ch = s.charAt(j);
if (ch === '\\') { j += 2; continue; }
if (ch === '`') { var cs = codeSpan(s, j); if (cs) { j = cs.end; continue; } j++; continue; }
if (ch === c) {
var len = 0;
while (s.charAt(j + len) === c) len++;
var before = s.charAt(j - 1) || '';
var after = s.charAt(j + len) || '';
if (len >= need && before && !/\s/.test(before) && (c !== '_' || !/[A-Za-z0-9]/.test(after))) {
return { index: j, len: len };
}
j += len; continue;
}
j++;
}
return null;
}
function emphAt(s, i) {
var c = s.charAt(i), run = 0;
while (s.charAt(i + run) === c) run++;
var next = s.charAt(i + run) || '';
if (!next || /\s/.test(next)) return null;
if (c === '_' && /[A-Za-z0-9]/.test(s.charAt(i - 1) || '')) return null; /* keep snake_case whole */
if (c === '~') {
if (run < 2) return null;
var cl = findCloser(s, i + 2, '~', 2);
if (!cl) return null;
return { html: '<del>' + inline(s.slice(i + 2, cl.index)) + '</del>', end: cl.index + 2 };
}
var tries = run >= 3 ? [3, 2, 1] : run >= 2 ? [2, 1] : [1];
for (var t = 0; t < tries.length; t++) {
var k = tries[t];
var close = findCloser(s, i + k, c, k);
if (!close) continue;
var innerSrc = s.slice(i + k, close.index);
if (!innerSrc) continue;
var inner = inline(innerSrc);
var html = k === 3 ? '<em><strong>' + inner + '</strong></em>'
: k === 2 ? '<strong>' + inner + '</strong>'
: '<em>' + inner + '</em>';
return { html: html, end: close.index + k };
}
return null;
}
function linkAt(s, i) {
var depth = 0, j = i;
while (j < s.length) {
var ch = s.charAt(j);
if (ch === '\\') { j += 2; continue; }
if (ch === '`') { var cs = codeSpan(s, j); if (cs) { j = cs.end; continue; } }
if (ch === '[') { depth++; j++; continue; }
if (ch === ']') { depth--; j++; if (depth === 0) break; continue; }
j++;
}
if (depth !== 0 || s.charAt(j) !== '(') return null;
var textEnd = j - 1, k = j + 1, pd = 1, buf = '';
while (k < s.length && pd > 0) {
var c2 = s.charAt(k);
if (c2 === '\\') { buf += s.charAt(k + 1); k += 2; continue; }
if (c2 === '\n') return null;
if (c2 === '(') { pd++; buf += c2; k++; continue; }
if (c2 === ')') { pd--; if (pd === 0) { k++; break; } buf += c2; k++; continue; }
buf += c2; k++;
}
if (pd > 0) return null;
var dest = buf.trim(), title = '';
var tm = /^([\s\S]*?)\s+["'(]([\s\S]*)["')]$/.exec(dest);
if (tm) { dest = tm[1].trim(); title = tm[2]; }
if (/^<[\s\S]*>$/.test(dest)) dest = dest.slice(1, -1);
return { text: s.slice(i + 1, textEnd), url: dest, title: title, end: k };
}
function linkHtml(l) {
var url = safeUrl(l.url);
var body = inline(l.text) || esc(l.url.slice(0, 80));
if (!url) {
var why = l.url.trim() ? esc(l.url.slice(0, 60)) + ' is not an address this renderer will follow'
: 'this link has no address';
/* `[]()` has neither text nor address: showing the empty body would leave
an invisible element on the page, so fall back to the literal source. */
return '<span class="blocked" title="link removed: ' + why + '">' +
(body || esc('[' + l.text + '](' + l.url.slice(0, 60) + ')')) + '</span>';
}
C.links++;
return '<a href="' + esc(url) + '"' + (l.title ? ' title="' + esc(l.title) + '"' : '') +
' rel="noopener noreferrer" target="_blank">' + body + '</a>';
}
function imgHtml(l) {
var label = l.text || l.title || 'image';
return '<span class="imgref" title="' + esc(l.url.slice(0, 80)) +
' is not loaded: this page makes no network requests">' + esc(label) + '</span>';
}
function autolink(s, i) {
var m = /^<((?:https?:\/\/|mailto:)[^\s<>]+)>/.exec(s.slice(i));
if (!m) return null;
var u = safeUrl(m[1]);
if (!u) return null;
C.links++;
return { html: '<a href="' + esc(u) + '" rel="noopener noreferrer" target="_blank">' + esc(m[1]) + '</a>',
end: i + m[0].length };
}
var ESCAPABLE = '\\`*_{}[]()#+-.!>|~';
function inline(s) {
var out = '', buf = '', i = 0, n = s.length;
function flush() { if (buf) { out += esc(buf); buf = ''; } }
while (i < n) {
var c = s.charAt(i);
if (c === '\\' && i + 1 < n && ESCAPABLE.indexOf(s.charAt(i + 1)) >= 0) {
buf += s.charAt(i + 1); i += 2; continue;
}
if (c === BR) { flush(); out += '<br>\n'; i++; continue; }
if (c === '`') { var cs = codeSpan(s, i); if (cs) { flush(); out += cs.html; i = cs.end; continue; } }
if (c === '!' && s.charAt(i + 1) === '[') {
var im = linkAt(s, i + 1);
if (im) { flush(); out += imgHtml(im); i = im.end; continue; }
}
if (c === '[') { var lk = linkAt(s, i); if (lk) { flush(); out += linkHtml(lk); i = lk.end; continue; } }
if (c === '<') { var au = autolink(s, i); if (au) { flush(); out += au.html; i = au.end; continue; } }
if (c === '*' || c === '_' || c === '~') {
var em = emphAt(s, i);
if (em) { flush(); out += em.html; i = em.end; continue; }
}
buf += c; i++;
}
flush();
return out;
}
/* ---------- blocks ---------- */
var RE_ITEM = /^(\s*)(?:([-*+])|(\d{1,9})[.)])([ \t]+|$)([\s\S]*)$/;
function isHr(line) { return /^\s{0,3}([-*_])[ \t]*(\1[ \t]*){2,}$/.test(line); }
function itemInfo(line) {
if (isHr(line)) return null;
var m = RE_ITEM.exec(line);
if (!m) return null;
var indent = m[1].length;
if (indent > 8) return null;
var ordered = !!m[3];
var markerLen = ordered ? m[3].length + 1 : 1;
var sp = m[4].length || 1;
return { indent: indent, ordered: ordered, start: ordered ? parseInt(m[3], 10) : 1,
content: m[5], contentIndent: indent + markerLen + sp };
}
function isTableSep(line) {
return line.indexOf('|') >= 0 &&
/^\s{0,3}\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line);
}
function isBlockStart(line) {
return /^\s{0,3}(#{1,6}(\s|$)|>|`{3,}|~{3,})/.test(line) || isHr(line) || !!itemInfo(line);
}
function splitRow(line) {
var s = line.trim();
if (s.charAt(0) === '|') s = s.slice(1);
if (s.charAt(s.length - 1) === '|' && s.charAt(s.length - 2) !== '\\') s = s.slice(0, -1);
var cells = [], cur = '';
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i);
if (c === '\\' && s.charAt(i + 1) === '|') { cur += '|'; i++; continue; }
if (c === '`') { var cs = codeSpan(s, i); if (cs) { cur += s.slice(i, cs.end); i = cs.end - 1; continue; } }
if (c === '|') { cells.push(cur); cur = ''; continue; }
cur += c;
}
cells.push(cur);
for (var k = 0; k < cells.length; k++) cells[k] = cells[k].trim();
return cells;
}
/* two trailing spaces (or a trailing backslash) mean "break the line here" */
function paraText(lines) {
return lines.join('\n').replace(/[ \t]{2,}\n/g, BR).replace(/\\\n/g, BR);
}
function parseList(lines, start, attr) {
var first = itemInfo(lines[start]);
var ordered = first.ordered, base = first.indent, n = lines.length;
var i = start, items = [], loose = false, anyTask = false;
while (i < n) {
var info = itemInfo(lines[i]);
if (!info || info.indent < base || info.indent > base + 3 || info.ordered !== ordered) break;
var ci = info.contentIndent, buf = [info.content], blanks = 0;
i++;
while (i < n) {
var l = lines[i];
if (!l.trim()) { blanks++; i++; continue; }
var ind = l.search(/\S/);
var ni = itemInfo(l);
if (ind >= ci) { /* indented continuation or nested list */
if (blanks) { buf.push(''); loose = true; blanks = 0; }
buf.push(l.slice(ci)); i++; continue;
}
if (ni && ni.indent >= base && ni.indent <= base + 3) { if (blanks) loose = true; break; }
if (!blanks && !isBlockStart(l)) { buf.push(l.trim()); i++; continue; } /* lazy wrap */
break;
}
items.push(buf.join('\n'));
}
var html = [];
for (var k = 0; k < items.length; k++) {
var src = items[k], cls = '', head = '';
var tm = /^\[([ xX])\][ \t]+([\s\S]*)$/.exec(src);
if (tm) {
anyTask = true;
var done = tm[1] !== ' ';
cls = ' class="task' + (done ? ' done' : '') + '"' +
' title="tick state comes from the source: edit [ ] or [x] on the left"';
head = '<input type="checkbox" disabled' + (done ? ' checked' : '') + '>';
src = tm[2];
}
var inner = blocks(src.split('\n'), 0, false);
if (!loose) inner = inner.replace(/^<p>([\s\S]*?)<\/p>/, '$1');
if (tm) inner = head + '<span class="tasktext">' + inner + '</span>';
html.push('<li' + cls + '>' + inner + '</li>');
}
var tag = ordered ? 'ol' : 'ul';
var listCls = anyTask ? ' class="tasks"' : '';
var startAttr = (ordered && first.start !== 1) ? ' start="' + first.start + '"' : '';
return { html: '<' + tag + listCls + startAttr + attr + '>\n' + html.join('\n') + '\n</' + tag + '>', next: i };
}
function blocks(lines, offset, top) {
var out = [], i = 0, n = lines.length;
while (i < n) {
var line = lines[i];
if (!line.trim()) { i++; continue; }
var attr = top ? ' data-line="' + (offset + i) + '"' : '';
/* fenced code */
var fm = /^(\s{0,3})(`{3,}|~{3,})(.*)$/.exec(line);
if (fm && !(fm[2].charAt(0) === '`' && fm[3].indexOf('`') >= 0)) {
var indent = fm[1].length, mark = fm[2].charAt(0), len = fm[2].length;
var lang = (fm[3].trim().split(/\s+/)[0] || '');
var closeRe = new RegExp('^\\s{0,3}\\' + mark + '{' + len + ',}\\s*$');
var dedent = new RegExp('^\\s{0,' + Math.max(indent, 1) + '}');
var body = [];
i++;
while (i < n) {
if (closeRe.test(lines[i])) { i++; break; }
body.push(indent ? lines[i].replace(dedent, '') : lines[i]);
i++;
}
C.code++;
out.push('<pre' + attr + (lang ? ' data-lang="' + esc(lang) + '"' : '') +
'><code>' + esc(body.join('\n')) + '</code></pre>');
continue;
}
/* heading */
var hm = /^(\s{0,3})(#{1,6})([ \t]+([\s\S]*?))?[ \t]*$/.exec(line);
if (hm) {
var lvl = hm[2].length;
var txt = (hm[4] || '').replace(/[ \t]+#+[ \t]*$/, '');
C.heads++;
out.push('<h' + lvl + ' id="' + esc(slug(txt || 'section')) + '"' + attr + '>' +
inline(txt) + '</h' + lvl + '>');
i++; continue;
}
/* thematic break */
if (isHr(line)) { out.push('<hr' + attr + '>'); i++; continue; }
/* quote */
if (/^\s{0,3}>/.test(line)) {
var q = [];
while (i < n) {
var lq = lines[i];
if (/^\s{0,3}>/.test(lq)) { q.push(lq.replace(/^\s{0,3}>[ \t]?/, '')); i++; continue; }
if (lq.trim() && !isBlockStart(lq)) { q.push(lq); i++; continue; }
break;
}
out.push('<blockquote' + attr + '>\n' + blocks(q, 0, false) + '\n</blockquote>');
continue;
}
/* table */
if (line.indexOf('|') >= 0 && i + 1 < n && isTableSep(lines[i + 1])) {
var headCells = splitRow(line), sepCells = splitRow(lines[i + 1]);
var align = [];
for (var a = 0; a < sepCells.length; a++) {
var cL = sepCells[a].charAt(0) === ':';
var cR = sepCells[a].charAt(sepCells[a].length - 1) === ':';
align.push(cL && cR ? 'center' : cR ? 'right' : cL ? 'left' : '');
}
var t = ['<table>', '<thead>', '<tr>'];
for (var h = 0; h < headCells.length; h++) {
t.push('<th' + (align[h] ? ' style="text-align:' + align[h] + '"' : '') + '>' +
inline(headCells[h]) + '</th>');
}
t.push('</tr>', '</thead>', '<tbody>');
i += 2;
while (i < n && lines[i].trim() && lines[i].indexOf('|') >= 0 && !isBlockStart(lines[i])) {
var row = splitRow(lines[i]);
t.push('<tr>');
for (var d = 0; d < headCells.length; d++) {
t.push('<td' + (align[d] ? ' style="text-align:' + align[d] + '"' : '') + '>' +
inline(row[d] === undefined ? '' : row[d]) + '</td>');
}
t.push('</tr>');
i++;
}
t.push('</tbody>', '</table>');
out.push('<div class="tablewrap"' + attr + '>\n' + t.join('\n') + '\n</div>');
continue;
}
/* list */
if (itemInfo(line)) {
var lr = parseList(lines, i, attr);
out.push(lr.html);
i = lr.next;
continue;
}
/* paragraph */
var p = [line];
i++;
while (i < n && lines[i].trim() && !isBlockStart(lines[i])) {
if (lines[i].indexOf('|') >= 0 && i + 1 < n && isTableSep(lines[i + 1])) break;
p.push(lines[i]);
i++;
}
out.push('<p' + attr + '>' + inline(paraText(p)) + '</p>');
}
return out.join('\n');
}
function renderMarkdown(src) {
C.heads = 0; C.links = 0; C.code = 0; C.ids = {};
var lines = String(src).replace(/\r\n?/g, '\n').replace(/\t/g, ' ').split('\n');
return blocks(lines, 0, true);
}
/* =======================================================================
Source colouring for the editor overlay.
This paints COLOUR ONLY. Nothing here may change weight, style, size or
spacing, because the coloured copy sits exactly behind the textarea and
has to wrap on precisely the same characters. It is deliberately looser
than the renderer above: a missed span is a dull word, not wrong output.
======================================================================= */
function sp(cls, text) { return '<span class="' + cls + '">' + esc(text) + '</span>'; }
/* end index of a `...` span, or -1 */
function hlCodeRun(s, i) {
var n = 0;
while (s.charAt(i + n) === '`') n++;
var fence = s.substr(i, n), j = i + n;
while (j < s.length) {
var k = s.indexOf(fence, j);
if (k < 0) return -1;
var m = 0;
while (s.charAt(k + m) === '`') m++;
if (m === n) return k + n;
j = k + m;
}
return -1;
}
/* end index just past the closing run of an emphasis marker, or -1 */
function hlEmphEnd(s, i, c, len) {
var j = i + len;
while (j < s.length) {
var ch = s.charAt(j);
if (ch === '\\') { j += 2; continue; }
if (ch === c) {
var r = 0;
while (s.charAt(j + r) === c) r++;
if (r >= len && !/\s/.test(s.charAt(j - 1) || ' ')) return j + len;
j += r; continue;
}
j++;
}
return -1;
}
function hlInline(s) {
var out = '', buf = '', i = 0, n = s.length;
function flush() { if (buf) { out += esc(buf); buf = ''; } }
while (i < n) {
var c = s.charAt(i);
if (c === '\\' && i + 1 < n && ESCAPABLE.indexOf(s.charAt(i + 1)) >= 0) {
flush(); out += sp('t-esc', s.substr(i, 2)); i += 2; continue;
}
if (c === '`') {
var ce = hlCodeRun(s, i);
if (ce > 0) { flush(); out += sp('t-code', s.slice(i, ce)); i = ce; continue; }
}
if (c === '[' || (c === '!' && s.charAt(i + 1) === '[')) {
var lk = linkAt(s, c === '!' ? i + 1 : i);
if (lk) {
flush();
var raw = s.slice(i, lk.end), bo = c === '!' ? 2 : 1, cut = raw.lastIndexOf('](');
if (cut > 0) {
var dest = raw.slice(cut + 2, -1), gap = dest.search(/\s/);
out += sp('t-mk', raw.slice(0, bo)) + hlInline(raw.slice(bo, cut)) + sp('t-mk', '](') +
(gap > 0 ? sp('t-url', dest.slice(0, gap)) + sp('t-meta', dest.slice(gap))
: sp('t-url', dest)) + sp('t-mk', ')');
} else {
out += sp('t-url', raw);
}
i = lk.end; continue;
}
}
if (c === '<') {
var am = /^<(?:https?:\/\/|mailto:)[^\s<>]+>/.exec(s.slice(i));
if (am) { flush(); out += sp('t-url', am[0]); i += am[0].length; continue; }
}
if (c === '*' || c === '_' || c === '~') {
var run = 0;
while (s.charAt(i + run) === c) run++;
var len = c === '~' ? (run >= 2 ? 2 : 0) : (run > 3 ? 3 : run);
var after = s.charAt(i + run);
if (len && after && !/\s/.test(after)) {
var end = hlEmphEnd(s, i, c, len);
if (end > 0) {
flush();
out += sp('t-mk', s.substr(i, len)) + hlInline(s.slice(i + len, end - len)) +
sp('t-mk', s.substr(end - len, len));
i = end; continue;
}
}
}
buf += c; i++;
}
flush();
return out;
}
/* st carries the open fence across lines */
function hlLine(line, st) {
if (st.fence) {
var mark = st.fence.charAt(0);
if (new RegExp('^\\s{0,3}\\' + mark + '{' + st.fence.length + ',}\\s*$').test(line)) {
st.fence = null;
return sp('t-mk', line);
}
return sp('t-code', line);
}
var fm = /^(\s{0,3})(`{3,}|~{3,})(.*)$/.exec(line);
if (fm && !(fm[2].charAt(0) === '`' && fm[3].indexOf('`') >= 0)) {
st.fence = fm[2];
return esc(fm[1]) + sp('t-mk', fm[2]) + sp('t-meta', fm[3]);
}
if (isHr(line)) return sp('t-mk', line);
var hm = /^(\s{0,3})(#{1,6})([ \t][\s\S]*|)$/.exec(line);
if (hm) return esc(hm[1]) + sp('t-mk', hm[2]) + '<span class="t-head">' + hlInline(hm[3]) + '</span>';
var qm = /^((?:\s{0,3}>[ \t]?)+)([\s\S]*)$/.exec(line);
if (qm) return sp('t-mk', qm[1]) + '<span class="t-quote">' + hlInline(qm[2]) + '</span>';
var lm = /^(\s*)([-*+]|\d{1,9}[.)])([ \t]+)([\s\S]*)$/.exec(line);
if (lm) {
var rest = lm[4], box = '';
var tm = /^(\[[ xX]\])([ \t]+)([\s\S]*)$/.exec(rest);
if (tm) { box = sp('t-mk', tm[1]) + esc(tm[2]); rest = tm[3]; }
return esc(lm[1]) + sp('t-mk', lm[2]) + esc(lm[3]) + box + hlInline(rest);
}
if (line.indexOf('|') >= 0 && /\|[\s\S]*\|/.test(line)) {
if (/^\s{0,3}\|?[\s:|-]+$/.test(line) && /-/.test(line)) return sp('t-mk', line);
var parts = line.split('|'), cells = [];
for (var k = 0; k < parts.length; k++) cells.push(hlInline(parts[k]));
return cells.join(sp('t-mk', '|'));
}
return hlInline(line);
}
/* =======================================================================
UI
======================================================================= */
function $(id) { return document.getElementById(id); }
var ed = $('ed'), mirror = $('mirror'), prose = $('prose'), pane = $('pane');
var panes = $('panes'), htmlOut = $('htmlOut'), htmlPane = $('htmlPane');
var optSync = $('optSync'), syncWrap = $('syncWrap'), copyBtn = $('copy'), status = $('status');
var fmtBtns = document.querySelectorAll('[data-fmt]'), fmtGrp = $('fmtGrp');
var ZWSP = String.fromCharCode(0x200B);
var DOCS = {}, docKeys = ['tour', 'notes', 'readme'], currentDoc = 'tour';
var DOC_NAMES = { tour: 'Feature tour', notes: 'Team notes', readme: 'Project README' };
for (var q = 0; q < docKeys.length; q++) {
var node = $('doc-' + docKeys[q]);
DOCS[docKeys[q]] = node ? node.textContent.replace(/^\n/, '').replace(/\s+$/, '') + '\n' : '';
}
function num(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
function plural(n, word) { return word + (n === 1 ? '' : 's'); }
/* the source textarea is display:none in Preview and HTML view */
function editorVisible() { return ed.offsetParent !== null; }
/* ---------- render ---------- */
var lastHtml = '', rafId = 0;
/* The HTML tab is a plain text dump; when there is no markdown there is no
markup either, so say so rather than showing an empty panel. */
function showHtml() {
htmlOut.className = 'htmlsrc' + (lastHtml ? '' : ' empty');
htmlOut.textContent = lastHtml ||
'The document is empty, so the parser produced no markup. Type on the Write tab and this fills in.';
}
function doRender() {
var src = ed.value;
var t0 = performance.now();
var html = renderMarkdown(src);
var ms = performance.now() - t0;
lastHtml = html;
prose.innerHTML = html ||
'<p class="emptyish">Nothing yet — type some markdown on the left and it shows up here.</p>';
var text = html ? prose.textContent : '';
var words = text.trim() ? text.trim().split(/\s+/).length : 0;
var lines = src.split('\n').length;
$('sWords').textContent = num(words);
$('sChars').textContent = num(src.length);
$('sLines').textContent = num(lines);
$('sRead').textContent = (words ? Math.max(1, Math.round(words / 220)) : 0) + ' min';
$('sHead').textContent = num(C.heads);
$('sLinks').textContent = num(C.links);
$('sCode').textContent = num(C.code);
$('sTime').textContent = (ms >= 0.005 ? ms.toFixed(2) : '<0.01') + ' ms';
var nblocks = html ? prose.children.length : 0;
$('chipLines').innerHTML = '<b>' + num(lines) + '</b> ' + plural(lines, 'line');
$('chipBlocks').innerHTML = '<b>' + num(nblocks) + '</b> ' + plural(nblocks, 'block');
$('chipBytes').innerHTML = '<b>' + num(lastHtml.length) + '</b> ' + plural(lastHtml.length, 'char');
if (panes.getAttribute('data-view') === 'html') showHtml();
anchors = null;
if (hlOn) { buildMirror(); positionMirror(); }
}
/* render on the next frame; a timer backs it up in case frames are not
being produced (background tab, reduced-motion tooling, headless runs) */
function schedule() {
if (rafId) return;
var done = false, toId = 0;
var go = function () {
if (done) return;
done = true; rafId = 0;
clearTimeout(toId);
doRender();
};
rafId = requestAnimationFrame(go) || -1;
toId = setTimeout(go, 40);
}
function onInput() {
mirrorDirty = true;
anchors = null;
schedule();
}
/* ---------- scroll sync, anchored on real source lines ----------
A hidden mirror of the textarea (same font, same width, same wrapping)
gives the pixel offset of any source line. Each top-level rendered block
carries the line it came from, so the two panes line up block by block
instead of by a crude percentage. ------------------------------------- */
var mirrorDirty = true, anchors = null, hlOn = false, plainMode = false;
var editCard = document.querySelector('.card--edit');
/* Never measure while the editor card is hidden: a resize in Preview or
HTML view would otherwise pin the overlay to 0px and leave it garbled
when Split comes back. */
function syncMirrorWidth() {
var w = ed.clientWidth;
if (w > 0) mirror.style.width = w + 'px';
}
function buildMirror() {
if (!mirrorDirty) return;
syncMirrorWidth();
var src = ed.value.split('\n'), frag = document.createDocumentFragment();
var big = src.length > 2000; /* colour is not worth a stutter */
if (big !== plainMode) {
plainMode = big;
if (hlOn) editCard.className = 'card card--edit' + (big ? '' : ' hl');
}
var st = { fence: null };
for (var i = 0; i < src.length; i++) {
var d = document.createElement('div');
d.className = 'mline';
var painted = big ? '' : hlLine(src[i], st); /* runs even for blank lines: it carries fence state */
if (src[i] === '') d.textContent = ZWSP;
else if (big) d.textContent = src[i];
else d.innerHTML = painted;
frag.appendChild(d);
}
mirror.textContent = '';
mirror.appendChild(frag);
/* a scrollbar can appear or vanish as a result of this very edit */
syncMirrorWidth();
mirrorDirty = false;
}
function positionMirror() {
if (hlOn) mirror.style.transform = 'translateY(' + (-ed.scrollTop) + 'px)';
}
/* Show the coloured overlay only once it demonstrably wraps identically to
the textarea. If a platform ever disagrees by more than a hair, the
overlay stays hidden and the editor is a plain textarea — same behaviour,
no misalignment. */
function tryEnableHl() {
if (!window.CSS || !CSS.supports || !CSS.supports('caret-color', 'red')) return;
mirrorDirty = true;
buildMirror();
if (Math.abs(mirror.scrollHeight - ed.scrollHeight) <= 3) {
hlOn = true;
editCard.className = 'card card--edit hl';
positionMirror();
}
}
function getAnchors() {
if (anchors) return anchors;
buildMirror();
var mls = mirror.children, els = prose.querySelectorAll('[data-line]');
var maxE = Math.max(1, ed.scrollHeight - ed.clientHeight);
var maxP = Math.max(1, pane.scrollHeight - pane.clientHeight);
var A = [[0, 0]];
for (var i = 0; i < els.length; i++) {
var L = parseInt(els[i].getAttribute('data-line'), 10);
var ml = mls[L];
if (!ml) continue;
var ey = ml.offsetTop, py = els[i].offsetTop;
var last = A[A.length - 1];
if (ey > last[0] + 2 && py > last[1] + 2 && ey < maxE && py < maxP) A.push([ey, py]);
}
var tail = A[A.length - 1];
A.push([Math.max(tail[0] + 1, maxE), Math.max(tail[1] + 1, maxP)]);
anchors = A;
return A;
}
function mapY(A, y, from) {
var to = 1 - from;
if (y <= A[0][from]) return A[0][to];
for (var i = 0; i < A.length - 1; i++) {
var a = A[i], b = A[i + 1];
if (y <= b[from]) {
var span = b[from] - a[from];
var t = span > 0 ? (y - a[from]) / span : 0;
return a[to] + t * (b[to] - a[to]);
}
}
return A[A.length - 1][to];
}
var owner = null, ownerAt = 0;
function claim(who) {
var now = performance.now();
if (owner && owner !== who && now - ownerAt < 140) return false;
owner = who; ownerAt = now;
return true;
}
function syncActive() {
return optSync.checked && panes.getAttribute('data-view') === 'split';
}
ed.addEventListener('scroll', function () {
positionMirror();
if (!syncActive() || !claim('ed')) return;
pane.scrollTop = mapY(getAnchors(), ed.scrollTop, 0);
});
pane.addEventListener('scroll', function () {
if (!syncActive() || !claim('pane')) return;
ed.scrollTop = mapY(getAnchors(), pane.scrollTop, 1);
});
/* ---------- editing helpers (undo stays intact via insertText) ---------- */
function replaceRange(a, b, text, selA, selB) {
ed.focus();
ed.setSelectionRange(a, b);
var ok = false;
try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
if (!ok) {
var v = ed.value;
ed.value = v.slice(0, a) + text + v.slice(b);
}
ed.setSelectionRange(selA, selB);
onInput();
}
function wrapInline(m, placeholder) {
var v = ed.value, a = ed.selectionStart, b = ed.selectionEnd, sel = v.slice(a, b), L = m.length;
if (a === b) {
replaceRange(a, b, m + placeholder + m, a + L, a + L + placeholder.length);
return;
}
if (v.slice(a - L, a) === m && v.slice(b, b + L) === m) { /* markers just outside */
replaceRange(a - L, b + L, sel, a - L, a - L + sel.length);
return;
}
if (sel.length >= 2 * L && sel.slice(0, L) === m && sel.slice(-L) === m) { /* markers inside */
var inner = sel.slice(L, -L);
replaceRange(a, b, inner, a, a + inner.length);
return;
}
replaceRange(a, b, m + sel + m, a + L, a + L + sel.length);
}
function linkOp() {
var v = ed.value, a = ed.selectionStart, b = ed.selectionEnd;
var sel = v.slice(a, b) || 'link text';
var url = 'https://example.com';
var text = '[' + sel + '](' + url + ')';
var us = a + sel.length + 3;
replaceRange(a, b, text, us, us + url.length);
}
function lineOp(kind) {
var v = ed.value, a = ed.selectionStart, b = ed.selectionEnd;
var s = v.lastIndexOf('\n', a - 1) + 1;
var e = v.indexOf('\n', b);
if (e < 0) e = v.length;
var src = v.slice(s, e).split('\n');
/* `re` finds a marker of this kind, `hit` finds the exact one this button
adds. Both keep the line's leading indentation in $1, so toggling a
nested bullet strips it instead of bulleting it a second time, and H2 on
an existing `#` heading changes its level rather than deleting it. */
var re, hit, pre;
if (kind === 'head') { re = /^([ \t]*)#{1,6}[ \t]+/; hit = /^([ \t]*)##[ \t]+/; pre = '## '; }
else if (kind === 'list') { re = hit = /^([ \t]*)[-*+][ \t]+/; pre = '- '; }
else { re = hit = /^([ \t]*)>[ \t]?/; pre = '> '; }
var all = true;
for (var i = 0; i < src.length; i++) {
if (src[i].trim() && !hit.test(src[i])) { all = false; break; }
}
var outLines = [];
for (var k = 0; k < src.length; k++) {
var l = src[k];
if (!l.trim()) { outLines.push(l); continue; }
outLines.push(all ? l.replace(re, '$1')
: re.test(l) ? l.replace(re, '$1' + pre)
: l.replace(/^([ \t]*)/, '$1' + pre));
}
var text = outLines.join('\n');
replaceRange(s, e, text, s, s + text.length);
}
function applyFmt(kind) {
/* Preview and HTML view hide the textarea. Editing it there would drop
text at whatever offset the caret was last left, invisibly and with no
undo (execCommand cannot run on a field that will not take focus), so
the buttons are disabled — this is the belt to that pair of braces. */
if (!editorVisible()) return;
if (kind === 'bold') wrapInline('**', 'bold text');
else if (kind === 'italic') wrapInline('*', 'italic');
else if (kind === 'code') wrapInline('`', 'code');
else if (kind === 'link') linkOp();
else lineOp(kind);
}
for (var f = 0; f < fmtBtns.length; f++) {
fmtBtns[f].addEventListener('click', function () { applyFmt(this.getAttribute('data-fmt')); });
}
ed.addEventListener('keydown', function (e) {
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
var k = String(e.key || '').toLowerCase();
if (k === 'b') { e.preventDefault(); applyFmt('bold'); }
else if (k === 'i') { e.preventDefault(); applyFmt('italic'); }
else if (k === 'k') { e.preventDefault(); applyFmt('link'); }
});
ed.addEventListener('input', onInput);
/* ---------- views ---------- */
var viewBtns = document.querySelectorAll('.seg button[data-view]');
function setView(v) {
panes.setAttribute('data-view', v);
for (var i = 0; i < viewBtns.length; i++) {
var on = viewBtns[i].getAttribute('data-view') === v;
viewBtns[i].setAttribute('aria-pressed', on ? 'true' : 'false');
}
optSync.disabled = v !== 'split';
syncWrap.style.opacity = v === 'split' ? '' : '.45';
/* There is nothing to sync when only one pane is on screen, and nothing
to format when the source pane is not — so both groups go quiet rather
than staying live over a document the reader cannot see. */
var canEdit = v === 'split' || v === 'write';
for (var g = 0; g < fmtBtns.length; g++) {
fmtBtns[g].disabled = !canEdit;
fmtBtns[g].title = canEdit ? fmtBtns[g].getAttribute('data-tip')
: 'Switch to Split or Write to edit the source';
}
fmtGrp.setAttribute('aria-disabled', canEdit ? 'false' : 'true');
if (v === 'html') showHtml();
anchors = null;
mirrorDirty = true;
/* the card is on screen again by now, so this measures a real width */
if (hlOn && (v === 'split' || v === 'write')) { buildMirror(); positionMirror(); }
}
for (var w = 0; w < viewBtns.length; w++) {
viewBtns[w].addEventListener('click', function () { setView(this.getAttribute('data-view')); });
}
/* ---------- documents ---------- */
var docBtns = document.querySelectorAll('[data-doc]');
function loadDoc(key, announce) {
currentDoc = key;
ed.value = DOCS[key] || '';
ed.scrollTop = 0; pane.scrollTop = 0; htmlPane.scrollTop = 0;
ed.setSelectionRange(0, 0);
for (var i = 0; i < docBtns.length; i++) {
docBtns[i].setAttribute('aria-pressed', docBtns[i].getAttribute('data-doc') === key ? 'true' : 'false');
}
mirrorDirty = true; anchors = null;
doRender();
if (announce) say('Loaded ' + (DOC_NAMES[key] || key) + '.');
}
for (var b = 0; b < docBtns.length; b++) {
docBtns[b].addEventListener('click', function () { loadDoc(this.getAttribute('data-doc'), true); });
}
$('reset').addEventListener('click', function () { loadDoc(currentDoc, true); });
function say(msg) { status.textContent = msg; }
/* ---------- copy the generated HTML ---------- */
var copyTimer = 0;
function flash(cls, label, msg) {
copyBtn.className = 'btn btn--mono ' + cls;
copyBtn.textContent = label;
say(msg);
clearTimeout(copyTimer);
copyTimer = setTimeout(function () {
copyBtn.className = 'btn btn--mono';
copyBtn.textContent = 'copy HTML';
}, 1800);
}
function fallbackCopy(text) {
try {
var t = document.createElement('textarea');
t.value = text;
t.setAttribute('readonly', '');
t.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';
document.body.appendChild(t);
t.select();
var ok = document.execCommand('copy');
document.body.removeChild(t);
return ok;
} catch (e) { return false; }
}
function selectHtmlSource() {
try {
var r = document.createRange();
r.selectNodeContents(htmlOut);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
} catch (e) { /* selection is a nicety, not a requirement */ }
}
function copied(ok) {
if (ok) {
flash('btn--ok', 'copied ' + String.fromCharCode(0x2713),
num(lastHtml.length) + ' characters of HTML copied to the clipboard.');
} else {
setView('html');
selectHtmlSource();
flash('btn--bad', 'copy blocked',
'The clipboard is closed to this page, so the HTML is on screen and selected instead.');
}
}
copyBtn.addEventListener('click', function () {
var text = lastHtml;
if (!text) { flash('btn--bad', 'nothing yet', 'There is no HTML to copy yet.'); return; }
var settled = false;
function finish(ok) { if (settled) return; settled = true; copied(ok); }
/* some sandboxes leave the clipboard promise hanging: never leave the
button without an answer */
var timer = setTimeout(function () { finish(fallbackCopy(text)); }, 1200);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { clearTimeout(timer); finish(true); },
function () { clearTimeout(timer); finish(fallbackCopy(text)); }
);
} else {
clearTimeout(timer);
finish(fallbackCopy(text));
}
});
optSync.addEventListener('change', function () {
say(optSync.checked ? 'Scroll sync on.' : 'Scroll sync off.');
});
window.addEventListener('resize', function () {
mirrorDirty = true; anchors = null; syncMirrorWidth();
if (hlOn) { buildMirror(); positionMirror(); }
});
/* ---------- go ---------- */
loadDoc('tour', false);
setView('split');
syncMirrorWidth();
tryEnableHl();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.