SWIFTPOINT//HUB
Support

Markdown reference — every syntax this site understands

What you may write on this site, which of the two engines will read it, and what each one does with the parts you might expect and will not get. The block synta

What you may write on this site, which of the two engines will read it, and what each one does with the parts you might expect and will not get. The block syntax has its own page, 13 Markdown blocks; the engine that builds /docs is described in 11 Guides system, and the honest limits are in 23 Known gaps.

Two engines, one syntax

This is the single most misunderstood thing about writing on this site, so it comes first.

There are two renderers. Both wrap markdown-it@15 (the default preset: CommonMark, plus tables and strikethrough), markdown-it-container@4 and highlight.js@11. They are configured differently and they are not interchangeable.

Surface Module Class prefix Who writes it
/docs guides — every file in docs/ — and the guides preview in the admin panel lib/docs/index.js d- staff, and us
Forum threads, replies, in-place post edits, POST /api/forum/preview lib/markdown.js, renderForumPost fr- anybody with an account
/updates entries lib/markdown.js via createRenderer({ prefix: 'up' }) in lib/updates.js up- staff

The guides engine is more capable: wikilinks, relative links, callouts, tabs, front matter. The posts engine is deliberately stricter, because its authors are strangers and its HTML is injected into somebody else's thread. The posts rules are not an accident of implementation — read Posts-only rules before assuming a guide trick will paste into a forum post.

Everything below is labelled by engine. Where the two differ, the difference is the point.

Standard markdown

CommonMark, with GFM tables and strikethrough, on both engines.

Syntax Guides (/docs) Posts and updates Notes
# H1 … ###### H6 Every heading gets a slugified id and tabindex="-1". The leading H1 is moved into the page header and stripped from the body, so a guide's H1 is its title, not its first line. Every heading gets an id of the form fr-h-<slug> (or up-h-<slug>), slug truncated to 48 characters. No table of contents. Duplicate heading text: the first is slug, the second slug-1, the third slug-2. The counter is per render, so it restarts in every tab pane and on every page. Guides list level 2 and deeper in the outline; a posts heading is linkable but never listed.
**bold**, __bold__ Yes Yes
*italic*, _italic_ Yes Yes
~~struck~~ Yes Yes GFM extension, enabled by the default preset.
`inline code` Yes Yes The backticks are removed; the content is escaped.
Fenced code Yes, with syntax highlighting Yes, with syntax highlighting See Syntax highlighting.
> blockquote Yes Yes A second > level nests.
- item Yes Yes * and + work too.
1. item Yes Yes The numbers in the source are ignored; the list is numbered from 1.
Nested lists Yes, indentation is preserved No — see the warning below
GFM tables Yes Yes The delimiter row is mandatory: |---|---:|.
--- (also ***, ___) Horizontal rule Horizontal rule
![alt](src) Yes, rewritten to an absolute /docs/… URL Yes, only http(s):// or a site path
[text](href) Yes, relative targets are resolved to a guide Yes, an allowlist decides
[text][ref] / [ref]: url Yes Yes
<https://example.com> Yes Yes
Bare https://example.com Linked (linkify) Linked (linkify) Linkify only fires with a scheme. www.example.com is not linked on either engine.
Setext headings (Title\n=====) Yes Yes === is an H1, --- under a line is an H2 — and a --- under a non-blank line is a setext H2, not a rule.
Hard line break: two trailing spaces, or a trailing \ Yes Yes
&copy;, &amp;, &lt; Decoded Decoded HTML entities are markdown, not HTML, so they survive html: false.

Nested lists: the posts warning

Posts and updates are normalised before the markdown parser ever sees them. cleanMarkdown() in lib/forum.js (duplicated verbatim in lib/updates.js) does, in order:

  1. CRLF and CR become LF
  2. control characters are removed
  3. every run of spaces and tabs becomes one space
  4. three or more blank lines become two
  5. leading indentation on every line is removed
  6. trailing whitespace is trimmed
  7. the result is capped — 8000 characters for a post, 32000 for an update

Steps 3 and 5 are why nesting does not survive in a post:

  • A nested list arrives as two flat lists, one after the other.
  • An indented code block — four spaces or a tab — arrives as an ordinary paragraph.

So: write code in a fenced block in a post or an update, never an indented one. In a guide, indentation is untouched and both work.

Tables

The delimiter row is always required. A table needs a header row, a delimiter row, and at least one body row:

| Syntax | Guides | Posts |
|---|---|---|
| `[[wikilink]]` | yes | no |
| `[link](./other.md)` | yes | refused unless it is a path |

What breaks it: a missing or empty delimiter row, a gap row in the middle, uneven column counts, or a cell whose text contains an unescaped |. Any of those stops the table and everything after the gap becomes paragraphs.

Syntax highlighting

Any language that hljs.getLanguage(lang) accepts is highlighted server-side into <pre class="hljs"><code>. There is no client-side highlighter and no language download: the fence is processed at render time.

Twenty-five ids that are known to work today:

bash, c, csharp, css, diff, dockerfile, elixir, go, graphql, haskell, html, ini, java, javascript, json, kotlin, lua, markdown, nginx, perl, php, powershell, python, ruby, rust, scala, scss, shell, sql, swift, toml, typescript, xml, yaml.

An unknown language still renders the fence, just unhighlighted — a fence tagged nonsense prints the code verbatim. There is no error and nothing in the page says the language was not recognised.

mermaid is not a highlight.js language, so a fence tagged mermaid prints as a plain code block of diagram source. There is no diagram renderer on this site; the way to show a flow is prose, a table, or an image.

On /docs only, two things are added around the HTML: every <pre> inside the prose gets a Copy button, and every image opens in a lightbox when it is clicked (public/js/docs.js). Neither happens on the forum.

Line breaks

Guides Posts and updates
breaks false true

Guides: a single newline is not a line break. End a line with two spaces or a \, or leave a blank line between the two paragraphs.

Posts and updates: a single newline is a <br>, which is why a support answer can be written as loose lines and still look right.

Everything else about the two engines is in 11 Guides system.

Guides-only features

None of the following exist on the forum or in /updates:

Feature Where it is documented
YAML front matter below
Wikilinks below
Obsidian > [!type] callouts below
Tabs below
Relative links and relative images below
Heading anchors, table of contents, prev/next, backlinks below
The guides preview (POST /api/admin/docs/preview) below and in 16 Admin panel

YAML front matter

The block must start on line 1 with ---; there is nothing above it, not even a blank line. Keys are lower-cased before they are read, so Title: and title: are the same key.

Only four keys are read. Everything else in the block is preserved by the editor and ignored by the renderer.

title

title: Markdown reference — every syntax this site understands

Quotes around the value are stripped. Falls back to the first # H1 in the body, then to the filename with - and _ turned into spaces. The title drives the <title>, the page heading, the OpenGraph and Twitter title, the sidebar label, the prev/next cards, backlink labels, the notes-map label (the part before the em dash), and the per-guide OG card.

description

description: What you may write, and what each engine does with it.

Everything after the first colon is the value, so a colon inside the description is fine. Quotes are stripped. Falls back to the first paragraph after the H1, stripped of markdown punctuation and cut at 160 characters — and with no H1 at all the fallback is the empty string, so a guide without an H1 and without a description gets no meta description.

tags

Three accepted forms.

Block list — items must be indented, and this is the form the admin panel writes:

tags:
  - reference
  - markdown

Inline list:

tags: [reference, markdown]

Single value:

tags: reference

Tags are compared case- and whitespace-insensitively everywhere they are filtered or counted, so #Tilt and tilt are one tag and a filter that says "no guides" when three exist cannot happen.

order

order: 120

Read with parseInt. Absent means 999. Folders sort before files, and a folder's README.md sorts first within it, so the shipped ones carry order: 0. Integers only: order: soon parses to NaN, every comparison against NaN is false, and the sort silently falls back to name order for that folder.

A complete front-matter block, in the shape the admin panel writes it:

---
title: Markdown reference — every syntax this site understands
description: What you may write, and what each engine does with it.
tags:
  - reference
  - markdown
order: 120
---

Front matter on save from the admin panel

lib/docs/write.js rebuilds the block on every save (buildFront()):

Key What happens
title always rewritten
description rewritten only when non-empty; an empty one is dropped
tags always rewritten as a block list, capped at 12 items
order rewritten only when it parses as a finite number, rounded
any other key kept verbatim, original line text and original position

The body is capped at 200000 characters. A save that changes nothing writes nothing at all.

The inline tag form does not survive a round trip. splitFront() reads tags: [reference, markdown] as one item, and buildFront() writes it back as:

tags:
  - [reference, markdown]

which the renderer then reads as the single tag [reference, markdown]. Use the block form.

[[note]]
[[note|label]]
[[note#section]]
[[note#section|label]]

Resolution order, first hit wins, all case-insensitive:

# Map Test
1 full slug the target, .md stripped
2 full slug <this guide's folder>/<target>
3 last path segment only when the target has no /
4 title the full title, first match wins

The link is /docs/<slug> plus # plus the slugified anchor.

Three things that do not work:

  • #section — an empty target resolves to nothing, so the reader sees the literal text #section. Write [#section](#section) instead.
  • An unresolved target degrades to its label, or to the bare target text. nope renders as the word nope. It never becomes a link to nowhere.
  • A wikilink whose text contains a backtick is left completely alone. That is the escape hatch, and it is the reason the rest of this page writes every wikilink inside a code fence.

Expansion skips lines inside fenced code blocks, so a sample in a guide is safe if it is in a fence.

In the guides preview the resolver is () => null, so wikilinks in the admin preview do not resolve — they show as their label text. That is deliberate: the preview endpoint has no index to resolve against, and a link that would 404 on publish is better shown as text than as a link that lies.

Link extraction for backlinks and the notes map is not fence-aware. A wikilink written inside a code sample still creates a backlink and a graph edge.

Obsidian callouts

Two forms, and they are not the same thing.

Form A — the > [!type] form (guides only)

> [!note] Optional title
> The body of the note.
>
> A second paragraph.
>
> - a list
> - works too

Rewritten to the container form before parsing. Rules:

  • the type is case-insensitive and must be one of the seven guides types
  • text after ] becomes the title
  • a blank > line is a paragraph break
  • a fenced code block inside stops the conversion — the callout ends there
  • an indented > [!note] is not converted
  • this form does not exist on the forum. On a post it is a plain blockquote whose first line reads [!note].

This form is worth using in guides that get exported to Discord, because toDiscord() understands it and turns it into > **NOTE** — title.

Form B — the container form (both engines)

::: note Optional title
The body.
:::

Seven types on guides, four on posts:

Type Guides default title Posts default title
note Note Note
info Info Good to know
tip Tip Tip
warning Warning Careful
danger Danger not registered
success Success not registered
example Example not registered

On the forum and in updates, danger, success and example are not containers at all: the ::: lines print as literal text around ordinary markdown, which is a loud, obvious failure rather than a silent one.

Titles are taken literally, quotes and all — only the whitespace is trimmed. If you want the quotes off the title, do not write them.

Tabs (guides only)

::: tabs
::: tab "Linux"
Content for the first tab.
::: tab "macOS"
Content for the second.
:::
:::

The closing rules are unusual and worth writing down:

  • ::: tabs must be exactly that. The rule is ^:::\s*tabs\s*$, so nothing may follow it — no title, no options, not even a comment. ::: tabs "Linux" is not a tabs block.
  • ::: tab "Title" opens a pane. The quotes are optional and stripped; without a title the button reads Tab.
  • a bare ::: closes the current pane, and the next non-blank line decides what happens: another ::: tab opens the next pane, and anything else makes that ::: the wrapper close as well. This is why the shape above has four ::: lines and not three.
  • an unclosed wrapper eats the rest of the document. There is no auto-closer for tabs.
  • three nesting levels render; a fourth prints Nested tabs are not supported. and nothing else.

Each pane's body is rendered with a fresh environment, and three things follow from that, all of which are silent:

  1. Headings inside a tab get ids counted from zero again, and they are not collected, so they appear in no outline. Two tabs with the same ## Install heading produce the same id twice.
  2. Relative links and relative images inside a tab resolve as if the guide were at the root of docs/, because the pane render is called without slugDir.
  3. A ::: steps inside a tab is the one thing this does not affect: a recording is addressed in docs/recordings/ and not against the guide, so it resolves from inside a tab exactly as it does outside one.

Inside a tab, use wikilinks or absolute /docs/... URLs.

resolveDocHref() tries, in order:

  1. a bare #anchor — left alone
  2. anything with a scheme (https:, mailto:, data:…) — left alone
  3. /docs/... — already a doc link, turned into an absolute site URL
  4. anything else starting with / — left alone
  5. relative to the guide's folder, with .md stripped; the target must exist as a file, or as <target>/README.md, and a .. segment is refused

In this folder, a sibling is therefore just a plain relative link:

The SPCF file format is described in [14 SPCF format](14-spcf-format.md).

The .md extension is optional when the file exists: [14 SPCF format](14-spcf-format) works too.

Images are simpler and less careful. A relative src is joined to the guide's folder and rewritten to /docs/<path> — with no existence check and no extension check. The renderer always emits:

<img class="d-zoom" src="…" alt="…" loading="lazy" referrerpolicy="no-referrer" />

There is no way to set a width, a height or a class: the tag is built by hand, so ![shot](x.png =400x200) does not do what it does on a wiki. Only .png .jpg .jpeg .webp .gif .svg .avif are served from /docs/<path>; anything else is a 404, even if the file is on disk.

Anchors. slugify() lowercases the heading, replaces every run of non-alphanumeric characters with a single -, trims leading and trailing dashes, and falls back to sec when nothing is left. Every heading carries tabindex="-1" so a link into the middle of a guide can move the keyboard focus there.

Outline. Captured while rendering, filtered to level 2 and deeper, and printed in the middle column as On this page. Headings inside tab panes are not in it, because tab bodies render with their own TOC array. An empty outline prints No sections. The outline highlights the current heading through an IntersectionObserver with a -20% 0px -70% 0px margin, and scrolls itself when the active entry moves out of view.

Prev/next come from the global reading order — st.flat, folders first, then by order — not from the folder the guide is in. The pager therefore crosses folder boundaries on purpose: the last guide in about/ is followed by the first guide in the next folder.

Backlinks are printed in the rail under Linked from, sorted by title. They are computed by scanning each guide's raw markdown for wikilinks and relative links — not from the rendered HTML — which is why a wikilink inside a code fence still counts.

Posts-only rules

createRenderer() in lib/markdown.js, in full:

new MarkdownIt({
  html: false,
  linkify: true,
  breaks: true,
  typographer: false,
  highlight: /* server-side highlight.js */,
});
Option Consequence
html: false Raw HTML is never parsed. <script>alert(1)</script> appears as escaped text on the page. There is no tag allowlist to maintain because there is no tag handling at all.
linkify: true Bare http:// and https:// URLs become links. www. does not.
breaks: true A single newline is a <br>.
typographer: false No smart quotes, no -- to an en dash, no ... to an ellipsis. Type ’, — and … directly if you want them.

Plus the normalisation described in Nested lists: cleanMarkdown(), capped at 8000 for a post and 32000 for an update.

safeUrl() allows exactly three shapes:

  • a site-relative path starting with a single /
  • a fragment starting with #
  • http:// or https:// with a host

Everything else is refused: javascript:, data:, vbscript:, blob:, file:, mailto:, tel:, ftp:, and a protocol-relative //host.

What a refusal looks like:

  • a refused link emits no tag at all. Not a tag without an href — no tag. [click](javascript:alert(1)) leaves the words click as plain text.
  • a refused image becomes a visible span carrying the alt text, so ./shot.png in a post shows whatever you wrote as alt text rather than disappearing. That is deliberate: a post that quietly loses its picture is a bug report nobody can file.

Attributes:

Posts and updates Guides
External link target="_blank" rel="noopener noreferrer nofollow ugc" rel="noopener", opened in the same tab
Internal link rel="noopener" rel="noopener"
Image src, alt, loading="lazy", referrerpolicy="no-referrer", class fr-img / up-img — nothing else class d-zoom, and a lightbox

Guides rely on markdown-it's own validateLink for hrefs rather than on this allowlist.

Changelog blocks and who may use them

::: added, ::: changed, ::: fixed, ::: removed, ::: security — the same vocabulary on all three surfaces, imported from one list so a sixth kind would be added in one place.

Type Badge word Guides Posts, by a member Posts, by staff Updates
added New yes text, no wrapper badge badge
changed Changed yes text, no wrapper badge badge
fixed Fixed yes text, no wrapper badge badge
removed Removed yes text, no wrapper badge badge
security Security yes text, no wrapper badge badge

On /docs there is no gate. In a post the decision is made at render time, from the author's standing (env.changelog, set from isStaffPerson):

  • moderators get the coloured entry with the badge
  • members get their own text with no wrapper at all — the fence is still consumed, so the words still show, but there is no badge and no border

Nothing in the database is rewritten. Promoting somebody starts rendering their existing posts correctly, immediately.

The preview endpoint uses the viewer's flag, not the author's, so a member previewing their own post sees what a member would get — which is the honest thing for a preview to show.

Updates always render them; only staff can write an update.

Why the gate exists at all. Hiding the toolbar buttons stops nobody. Markdown is plain text: anybody can type ::: security into a post and get the red Security badge — the one badge on this site that reads as "the people who run this are telling you to act". The gate is on the renderer, where it cannot be bypassed by typing.

Unclosed containers are repaired automatically

Before parsing, closeDanglingContainers() walks the source line by line and closes any container this surface knows about that the author left open. The set is:

note, info, tip, warning, added, changed, fixed, removed, spoiler

Rules:

  • any opening fence ends whatever was open, whether or not this surface manages the new one — a ::: fixed followed by a ::: steps is an entry and then an embed, not one thing inside the other
  • a closing ::: on its own line is the author's, not a synthetic one
  • a code fence — three backticks, or three tildes — inside a container ends that container's scope
  • a container still open at the end of the text is closed at the end

So three changelog entries written the way a person writes a list — no closing fences — render as three entries:

::: added
The tilt gesture.

:::
::: changed
The OLED timeout is now per profile.

:::
::: fixed
The importer no longer loses the second group.
:::

This never helps ::: steps, ::: video or ::: tabs. Those are not in the set. steps and video are consumed whole by their own block rules, so an unclosed one eats the rest of the post the same way. tabs is a guides-only block and has no repair pass at all.

Excerpts

plainText(source, max) produces the one-line, no-markup preview used by the thread list (160 characters) and by update teasers (200). In order:

  1. images become [image]
  2. links become their label text
  3. block-opening lines (::: …) become a space
  4. heading markers, > , - / * / + and 1. list markers are stripped
  5. *, _ and ~ are removed
  6. all whitespace collapses to single spaces, and the text is trimmed
  7. anything longer than max is cut on a word boundary and gets an ellipsis

Note that step 3 removes the fence line but keeps the words inside it, so a changelog entry contributes its text to the excerpt.

What is NOT supported

Guides

Not supported What happens instead
Raw HTML, HTML comments Escaped text on the page. <b>x</b> shows the tags.
Footnotes [^1] is literal text. There is no footnote plugin.
Definition lists Not a CommonMark construct; a paragraph followed by : term.
Math ($…$, $…$) Literal text. No math renderer.
Mermaid A fence tagged mermaid is a plain code block — see Syntax highlighting.
Task lists (- [ ], - [x]) A bullet whose text starts with a literal [ ].
Emoji shortcodes (:tada:) Literal text.
Nested containers — a ::: tip inside a ::: note Breaks. The closing ::: closes the outer container first, and the inner opener is left as text. Tabs, steps and video inside a callout break for the same reason.
#section Literal text #section.
::: tabs with anything after it Not a tabs block; prints as text.
Multi-line titles on ::: tab Only the first line is the title.
A single newline as a line break Use a blank line or a trailing \.
Image sizes, image classes, image titles The <img> tag is fixed.
Serving arbitrary files from /docs/… Seven image extensions and .rec.json, nothing else.

Posts and updates, additionally

Not supported What happens instead
> [!note] callouts A blockquote whose first line reads [!note].
Wikilinks Literal … text.
::: tabs Literal ::: lines around ordinary markdown.
Relative links and relative images A relative link target is refused as a URL; a relative image src is refused and shows its alt text.
::: danger, ::: success, ::: example Literal ::: lines.
The guides preview The preview endpoint is renderForumPost, not the guides engine.
Indentation for nesting or code Normalised away before parsing.

Cheat sheet

Everything that works, in one paste-able block:

# Heading through ###### heading (the first H1 becomes the page title)
**bold**  *italic*  ~~struck~~  `inline code`
> A blockquote.
- a bullet
- another bullet
1. a numbered item
| a | table |
|---|---|
| cell | cell |

- - -
[link](https://example.com)  [reference link][ref]  <https://example.com>
https://example.com becomes a link; www.example.com does not.
![alt text](https://example.com/a.png)
A hard break at the end of this line (two spaces)

[ref]: https://example.com

::: note Optional title
Body.
:::
::: tip
:::
::: warning
:::
::: info
:::
::: danger
:::
::: success
:::
::: example
:::
::: spoiler "Click to reveal"
Hidden text.
:::
::: added
:::
::: changed
:::
::: fixed
:::
::: removed
:::
::: security
:::
::: steps "Paint a trigger" recordings/paint.rec.json
:::
::: video "Title" https://youtu.be/VIDEOID at=1:20
:::

And the things that silently do not work — each of these renders, just not as what it looks like:

<script>alert(1)</script>        escaped text, both engines
[^1] and [^1]: a footnote        literal text
- [ ] a task                    a bullet reading "[ ] a task"
$t$ and $x$                   literal text
:tada:                          literal text
[[#section]]                    the text "#section"
::: tabs Linux                  literal text — the rule wants exactly "::: tabs"
::: danger                      a callout on a guide, literal text on a post
    indented code               a paragraph in a post
a single newline as a break     a space in a guide, a <br> in a post
![x](a.png =400x200)            the size is part of the path
:::

The editor toolbar and keyboard shortcuts

public/js/markdown-editor.js exports one constructor, window.SPMarkdown.create({...}), and window.SPMarkdown.MAX (8000, the default). All four editors on the site are that same code, so a fix lands in all four.

Surface blocks max Preview endpoint Draft key
New thread false 8000 POST /api/forum/preview, field body sp:forum:draft
Reply false 8000 POST /api/forum/preview, field body sp:forum:reply:<threadId>
In-place post edit false 8000 POST /api/forum/preview, field body none
Updates form true 32000 POST /api/forum/preview, field body sp:updates:draft
Guides editor (admin panel) true 32000 POST /api/admin/docs/preview, field content plus slug sp:admin:guide

blocks: true means the changelog row exists, and it also switches the bar to the two-row layout. A narrow forum composer keeps the single scrolling strip, which is the better use of the height there.

Row 1 — formatting

Every button wraps the selection and leaves the caret inside the markers, which is the one behaviour markdown toolbars usually get wrong. Wrapping something that is already wrapped unwraps it instead.

Button Inserts
B **bold text**
I *italic text*
S ~~struck text~~
</> `code`
{ } a fence — see the caveat below
Link prompts for the URL, then [text](https://)
IMG prompts for the URL, then ![what it shows](https://)
Steps ::: steps "Title" recordings/name.rec.json plus a closing :::
❝ > on every line the selection touches
• - on every line the selection touches
1. 1. on every line the selection touches
H ## on every line the selection touches

The Link and IMG buttons refuse anything that is not http:// or https:// with an alert, which matches the image allowlist rather than being a surprise.

The Steps button substitutes the current selection into the filename, slugified; with a selection it becomes the title instead. The caret lands between the fences, because a block with an empty line in it is a form and the typing starts there.

Row 2 — blocks

Button Inserts
Note ::: note … :::
Tip ::: tip … :::
! ::: warning … :::
Video ::: video "Title" https://youtu.be/ … :::
Spoiler ::: spoiler "Click to reveal" … :::

Two caveats worth knowing before you press them:

  • the Video button inserts an empty https://youtu.be/ with no prompt. Until you paste an id there, the preview shows No YouTube video at https://youtu.be/.
  • the { } button declares block: true, and that flag is never read — there is no code path that looks at it — so it behaves as an inline wrap and puts a three-backtick pair before and after the selection rather than opening a fence on its own line.

Row 3 — changelog

Only rendered when blocks: true, so only in the updates form and the guides editor:

New (::: added), Changed (::: changed), Fixed (::: fixed), Removed (::: removed), Security (::: security). Each inserts its fence with an empty line between, so the caret lands in the middle.

Write / Preview

The preview is rendered by the server, not by a JavaScript re-implementation, so what you see is what will be posted. It is debounced at 500 ms, guarded by a sequence number so a slow earlier render cannot land after a newer one, and aborted outright when you switch back to Write. While the preview is showing, the tools row is hidden but keeps its width, so switching back does not move the text under the cursor.

A ::: video inside a preview arrives as HTML, so its chapter chips are re-wired to the frame with SPVideo.init() after each render.

Counter and drafts

The counter reads n / MAX, gets is-near above 90 % and is-over at the limit, and the textarea carries a matching maxLength. The number the counter shows and the number the server keeps are the same number on every surface — test/limits.test.js fails if that ever stops being true.

Drafts are written to localStorage on every input and restored only into an empty box, so a draft can never overwrite something you are already typing. Clearing the box removes the draft.

Keyboard

Keys Result
Ctrl/Cmd + Enter Works. Submits, from onSubmit.
Ctrl/Cmd + B Broken.
Ctrl/Cmd + I Broken.
Ctrl/Cmd + K Broken.

The handler at public/js/markdown-editor.js:510 reads an undefined BUTTONS. The module declares FORMAT_BUTTONS, BLOCK_BUTTONS and ENTRY_BUTTONS and no combined list, so the line throws a ReferenceError after preventDefault() has already run. Nothing happens and the browser default is swallowed too, so the key appears dead. The one-word fix is FORMAT_BUTTONS. The toolbar buttons themselves work; only the shortcut is dead, and the tooltips still advertise the shortcut. This is written up in 23 Known gaps.

The Markdown link in the footer of every editor opens a panel with the core syntax, the block syntax, the video options (at=1:20, 0:46-1:25, chapter lines) and — only where blocks is on — the five changelog types.