SWIFTPOINT//HUB
Support

Markdown blocks — the ::: reference

Every ::: block on this site: the callouts, the changelog entries, spoilers, tabs, walkthrough embeds and video embeds, with their options, the HTML each one pr

Every ::: block on this site: the callouts, the changelog entries, spoilers, tabs, walkthrough embeds and video embeds, with their options, the HTML each one produces, and what each one does when it is wrong. The standard syntax is in 12 Markdown reference, and the toolbar that inserts these blocks is covered there too.

The block grammar

Every block on this site is a markdown-it-container: three colons, a name, and a body that runs until a line holding nothing but :::.

::: name Optional arguments
The body, which is ordinary markdown.
:::
Line Grammar Meaning
::: name ^:::\s*name\b Opens the block. The rest of the line is the container's parameters.
::: name "Title" ^:::\s*name\s+"([^"]*)" Opens it with a title. Only double quotes are recognised as a title delimiter, and only on spoiler, steps and video.
::: name free text ^:::\s*name\b(.*)$ The whole remainder becomes the parameters; what it means is per block.
::: ^:::\s*$ Closes it. Nothing may follow.

Rules common to every block:

  • The opener must start at the beginning of a line. An indented ::: note is not a block.
  • The name is matched case-insensitively for note, info, tip, warning, spoiler and the changelog names, and by first whitespace-separated word, so ::: note Optional title works and ::: note works too.
  • Parameters are taken literally after the name. Quotes stay in the title unless the block strips them itself — and only spoiler, steps and video do.
  • The body is ordinary markdown, except for steps, video and tabs, which are header-only or self-parsing.
  • A block is consumed whole: a paragraph written after the closing fence is a sibling of the block, not a child, and a paragraph written after an unclosed one does not leak out below it.
  • Blocks do not nest on the same level. A ::: inside a block closes that block, whether it was meant to open a new one or not.

::: note, info, tip, warning

The four callouts that exist on every surface. Both engines register the same four names with different default titles.

::: note
The default title.
:::

::: info Profiler
A callout with a title of your own.
:::

::: tip
Something that saves time.
:::

::: warning
Something that will bite.
:::
Option Effect
no arguments the engine's default title
a bare word or phrase that text becomes the title, quotes included
an empty line and more paragraphs several paragraphs, lists and tables are all allowed in the body
Type Guides default Posts and updates default
note Note Note
info Info Good to know
tip Tip Tip
warning Warning Careful

What it renders to. Guides:

<div class="d-callout d-callout--note" role="note">
<div class="d-callout-ttl">Note</div>
<div class="d-callout-bd"> … </div>
</div>

Posts and updates: an <aside class="fr-note fr-note-note" role="note"> with an fr-note-t heading paragraph and an fr-note-b body — up-* in an update.

When it is wrong.

  • A block left unclosed is repaired: the auto-closer adds the missing ::: at the next opening fence or at the end. See the note on repair below.
  • A ::: block inside another block closes the outer one. There is no nesting.
  • On the forum, ::: info Good to know shows that title, not Info.
  • A title with quotes shows the quotes. Type ::: note Careful here, not ::: note "Careful here".

::: danger, success, example

The three guides-only callouts. Same syntax, same container, three more names — and they are not registered on the posts engine, which is the whole difference.

::: danger
Do not paste a session key into a forum thread.
:::

::: success
Published to `docs/recordings/` means it is on the site.
:::

::: example
:::
::: steps "Paint a trigger" recordings/paint-a-trigger.rec.json
:::
Type Guides default title On a post or an update
danger Danger not a container — the ::: lines print as text
success Success not a container
example Example not a container

On the forum the failure is loud: ::: danger renders as the literal characters ::: danger above the body and another ::: below it. Nothing is lost, and it cannot be mistaken for a working callout.

::: example wrapping a ::: steps does not nest — the inner opener's ::: closes the example first. Keep the embed outside the callout.

Why the Obsidian form exists in guides

A guide can also use the Obsidian callout form, which the guides engine rewrites into the container form before parsing:

> [!danger] Do not do this
> The body, as quote lines.

That form is what makes the Discord export work: toDiscord() recognises > [!type] and rewrites it to > **DANGER** — Do not do this, because Discord has no callouts and a quoted block with a bold label is what a Discord reader can read. Plain ::: blocks do not survive that export as callouts — they lose their type. Details in 11 Guides system.

::: added, changed, fixed, removed, security

The changelog vocabulary. One list in lib/markdown.js, imported by both engines, so the same five names mean the same five things on the forum, in an update and in a guide.

::: added
The tilt gesture now reads the current group before it fires.
:::

::: fixed Profiler's duplicate check
A profile with two identical triggers no longer saves.
:::

::: security
The session cookie is cleared on every password change.
:::
Type Badge word Wording of the badge
added Added New
changed Changed Changed
fixed Fixed Fixed
removed Removed Removed
security Security Security

Options: the rest of the opener line, if any, becomes a title next to the badge. ::: fixed Profiler's duplicate check is Fixed + Profiler's duplicate check. Without it there is only the badge, because "Fixed" next to "Fixed" is the kind of small stupidity that makes a changelog look as though nobody read it.

What it renders to. Guides:

<section class="d-entry d-entry-fixed" role="note" aria-label="Fixed">
<p class="d-entry-hd"><span class="d-entry-badge">Fixed</span><span class="d-entry-title">…</span></p>
<div class="d-entry-bd"> … </div>
</section>

Posts: fr-entry fr-entry-fixed with fr-entry-t, fr-entry-badge, fr-entry-title and fr-entry-b. Updates: the same with up-.

The role gate. On /docs there is none. In a post, the block renders as an entry only when env.changelog is not false, which the forum sets from the author's standing. A member's block renders as the body text with no wrapper at all — the fence is still consumed, so the words still show, and there is no badge and no border. Promoting somebody makes their existing posts render as entries immediately; nothing is written back to the database. Updates always render them.

The reason is that hiding the toolbar buttons stops nobody. Markdown is plain text, so anybody can type ::: security and be handed the one badge that reads as "the people who run this are telling you to act". The decision is in the renderer, where typing is not enough.

The preview endpoint decides with the viewer's flag, not the author's, so a member previewing sees exactly what a member gets.

The auto-closer, in practice

Three entries written the way a person writes a list, with no closing fences, render as three entries:

::: added
The tilt gesture reads the current group first.

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

:::
::: security
Sessions are cleared on a password change.
:::

The repair pass runs before parsing, over the names note, info, tip, warning, added, changed, fixed, removed and spoiler. Any opening fence ends whatever was open — including one this surface does not manage, so a ::: fixed followed by a ::: steps is an entry and then an embed. A code fence inside a container ends that container's scope, because a ::: in a code sample is not a fence.

The repair pass does not cover ::: steps, ::: video or ::: tabs. Those three eat the rest of the text when left open. On the guides side the pass deliberately excludes steps for a second reason: closing it would put a stray ::: at the bottom of every guide that embeds a walkthrough.

::: spoiler

A <details>, closed, with no JavaScript at all.

The summary row carries the disclosure, because a <details> that looks like a heading is not a disclosure: a caret on the left (rotated 90° while open) and the word Show, which becomes Hide. The label is the part that cannot be missed — the caret alone was a 9px triangle in the faintest grey at the far edge of a full-width row. All three class prefixes style it (docs.css, ui.css, updates.css).

::: spoiler "The answer"
Set the order to:

1. Wheel up
2. Wheel down
3. Twist
4. Click

Then save. The order matters and the tooltip does not say so.
:::

::: spoiler
An untitled spoiler is labelled "Click to reveal".
:::
Option Effect
nothing title Click to reveal
"Quoted title" the quotes are stripped and the inner text becomes the summary label
bare text used as the title, quotes and all

Unlike the callouts, this one strips a matched pair of double quotes around the whole title. ::: spoiler "The answer" is labelled The answer; ::: spoiler "The "answer"" is not a matched pair, so the quotes stay. That asymmetry is deliberate: a summary is the label of a button somebody clicks, so a quoted string there reads as punctuation rather than as a name.

What it renders to.

<details class="d-spoiler"><summary class="d-spoiler-t">The answer</summary><div class="d-spoiler-b"> … </div></details>

Posts: fr-spoiler, fr-spoiler-t, fr-spoiler-b.

Why a native element. No toggle handler, no state to get wrong, and it opens when JavaScript does not run — which is the situation where a reader most needs it.

::: tabs

Guides only.

::: tabs
::: tab "Linux"
The first pane. Ordinary markdown, including other blocks.
::: tab "macOS"
The second pane.
::: tab "Windows"
The third.
:::
:::

Grammar:

Line Meaning
::: tabs Opens the wrapper. Must be exactly this — the rule is ^:::\s*tabs\s*$, so no title, no options, nothing after it.
::: tab "Title" Opens a pane. Quotes optional and stripped; without a title the button reads Tab.
bare ::: Closes the current pane. The next non-blank line decides: another ::: tab opens the next pane, anything else makes this ::: close the wrapper too.
the last ::: Closes the wrapper.

Three nesting levels render. A fourth prints Nested tabs are not supported. and nothing else. There is no auto-closer: an unclosed wrapper consumes the rest of the document.

What it renders to. A .d-tabs wrapper, a .d-tabs-nav of <button class="d-tab-btn"> with the first marked is-active, and one .d-tab-pane per pane with data-tab="<title>", each pane's HTML inside .d-tab-pane-in.

The three consequences of a fresh environment. Each pane body is rendered with a new env, and everything about that leaks:

Consequence Why it matters
Pane headings get ids counted from zero again and are collected nowhere Two panes with ## Install produce the same id twice, and neither appears in the outline
Relative links and images resolve as if the guide were at the root of docs/ A tab that says [x](../other.md) resolves from the wrong folder
A ::: steps inside a tab works, because a recording does not live in the guide's folder The "Missing steps" callout appears for a recording that is right there

Use wikilinks or absolute /docs/... URLs inside a tab.

::: steps

An interactive walkthrough recorded in the Profile Builder. See 08 Walkthrough recorder.

::: steps "Paint a trigger" recordings/paint-a-trigger.rec.json
:::

Grammar, on guides:

Part Meaning
"Title" Double quotes are required for a title. Only ^"([^"]*)"\s*(.*)$ is recognised; without the leading quote the whole rest of the line is treated as the path
the rest the path to a .rec.json. It is not relative to the guide: it is addressed in docs/recordings/
the body ignored, and consumed. Anything between the fences is read and thrown away

A recording lives in one directory, docs/recordings/, and the path says so. recordings/x.rec.json is the form to write; a prefix (profile-builder/recordings/x.rec.json) and a leading /docs/ are both accepted and normalised to the same address. The guide's own folder is not an input, which is why a guide at the root of docs/ and one in profile-builder/ resolve the same recording. A path containing a .. segment is refused rather than normalised away, and so is anything that does not end in .rec.json.

When a recording counts as present. Either the .rec.json is a file under docs/recordings/, or a row with that slug exists in the recordings table. Matching is by basename: recordings/paint.rec.json and profile-builder/recordings/paint.rec.json are the same recording, and both are served from /docs/recordings/paint.rec.json. Only a path that really ends in .rec.json is served over HTTP — the step count is read from the table when the row exists, and from the file's own steps array when it does not.

What it renders to. The box the player mounts into, and nothing else — the step list is fetched and built client-side:

<div class="d-steps" data-src="https://<host>/docs/recordings/paint-a-trigger.rec.json" data-title="Paint a trigger" role="region" aria-label="Paint a trigger"></div>

public/js/steps-player.js mounts on both .d-steps and .fr-steps, adding a Open live in Profile Builder link for everyone and a Manage this recording link for admins. If the fetch fails the box reads This walkthrough could not be loaded right now.

When it is wrong. No title quotes, a typo in the path, or a .. in the path all land on the same callout:

Missing steps No recording at recordings/paint-a-trigger.rec.json. Paint it with the ● Record button in the Profile Builder, or upload it on the recordings page.

That is the fallback verbatim, with the path inside a <code>. It is a callout, not an error page, on purpose: a guide is committed to the repository and the recording may simply not be published yet.

The snippet publish() returns. Publishing a recording writes the file into docs/recordings/<slug>.rec.json, marks the row published, and returns the four values to paste into a guide:

{
  ok: true,
  file: 'docs/recordings/paint-a-trigger.rec.json',
  embed: '::: steps "Paint a trigger" recordings/paint-a-trigger.rec.json',
  dataSrc: '/docs/recordings/paint-a-trigger.rec.json',
}

The embed line is the same in every guide, because the file went into the one recordings directory rather than into a guide's folder. A recording is a shared asset: two guides can embed the same walkthrough, and it must not live in either of them.

The same block on the forum

Different in three ways:

Guides Forum and updates
Folder in the path normalised away — always the one recordings/ ignored
URL always /docs/recordings/<slug>.rec.json always rebuilt as /docs/recordings/<slug>.rec.json
Not found, unpublished, or unknown Missing steps callout No walkthrough at <code>…</code>.
Updates — there is no resolver at all, so a ::: steps in an update always says No walkthrough at …

The forum resolver reduces whatever is written to a basename slug first and then asks the table, so a post cannot walk out of the recordings directory, and an unpublished recording has no public URL to embed.

::: video

A YouTube player, and the only markup on this site that loads a third party.

::: video "Recording the tilt gesture" https://youtu.be/dQw4w9WgXcQ
:::

Grammar:

::: video "Title" <link or 11-character id> [size=…] [at=1:20 | 0:46-1:25]
Part Meaning
"Title" Double-quoted, same rule as steps. Without it the caption reads Video.
the source a bare 11-character id, or a link
size= small (360px), medium (640px), large (the default width), or a pixel count clamped to 240–1600
at=1:20, start=1:20 or a bare 1:20 start there. The three spellings are interchangeable
0:46-1:25 play only that range. The end must be later than the start

Accepted sources

Written as Accepted
dQw4w9WgXcQ yes — a bare [A-Za-z0-9_-]{11} id is a first-class input
https://youtu.be/dQw4w9WgXcQ yes
https://www.youtube.com/watch?v=dQw4w9WgXcQ yes
https://m.youtube.com/watch?v=… yes
https://music.youtube.com/watch?v=… yes
https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ yes
…/shorts/…, …/live/…, …/v/… yes
anything else No YouTube video at <code>…</code>.

With or without www.. Angle brackets around the link are stripped, so <https://youtu.be/…> works.

The host is checked, not sniffed, because this text is somebody else's and it ends in an iframe: ?v= on some other domain comes back empty. And the eleven-character test is what actually lands in the URL — everything upstream of it only decides which eleven characters.

Options

::: video "The short version" https://youtu.be/VIDEOID size=small at=1:20
:::

::: video "Just the gesture" https://youtu.be/VIDEOID 0:46-1:25
:::

::: video "Fixed width" https://youtu.be/VIDEOID size=720
:::

Options are read off the end of the line, up to four tokens. A link contains no spaces, so a token that is neither key=value nor two clocks joined by a dash can only be part of the link — and a recognised token is always taken off, then applied only if it is the first of its kind. A mistyped size=huge costs you the width, never the video.

Chapters

Lines between the fences are chapters. Every non-blank line must be a clock, optionally followed by a label and an optional separator (-, –, —, >, ·, :):

::: video "Walkthrough" https://youtu.be/VIDEOID
- 0:46 The tilt gesture
- 1:25 The group it reads
- 2:04 Saving and reloading
:::
  • One list marker is stripped first, so writing - 0:46 … works.
  • Ordered markers do not have to be in order.
  • If any non-blank line is not a clock, the whole list is discarded — and so is the body. Nothing is printed under the player. This is not a typo tolerance: guessing would mean a mistyped caption turning into timestamps.
  • Chapters are never shown as text on the page.

A time written in the pasted link is honoured when the block writes none: ?t=1507, ?t=25m7s, ?t=1h2m3s, ?start=46, #t=46, all understood and clamped to seven days. An explicit range or start wins over a time in the link.

What it renders to

<figure class="fr-video" role="group" aria-label="Walkthrough" data-video-id="VIDEOID" data-video-start="46" data-video-end="85">
<div class="fr-video-frame">
<iframe src="https://www.youtube-nocookie.com/embed/VIDEOID?rel=0&amp;modestbranding=1&amp;enablejsapi=1&amp;start=46" title="Walkthrough" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>
</div>
<div class="fr-video-chips"> … </div>
<figcaption class="fr-video-cap">Walkthrough<span class="fr-video-range">plays 0:46–1:25</span><a class="fr-video-link" href="https://www.youtube.com/watch?v=VIDEOID&amp;t=46" rel="noopener noreferrer" target="_blank">Watch on YouTube</a></figcaption>
</figure>

The d- prefix on a guide, fr- on the forum, up- in an update. Chapters become real <a href="…/watch?v=…&t=…"> chips; public/js/video-player.js upgrades a click into a postMessage seek on the frame already on the page, and stops the player at data-video-end. The chips are real links first, so with scripting off, blocked or broken a chip is still a link to the right second on YouTube, and a range that cannot be enforced simply does not stop.

Why youtube-nocookie.com. It sets no cookie until somebody presses play, so a reader who scrolls past a video in a thirty-post thread has not been tracked. That is the entire difference between the two hostnames and it costs nothing.

Why there is no generic oembed. A generic embed proxy is how a forum becomes a way to load any page on the internet in a frame sized to look like a video. The vocabulary is closed on purpose: YouTube, and nothing else.

Why enablejsapi=1 and not the player API script. The API is a third-party script, and script-src 'self' is right to refuse it. Admitting www.youtube.com to script-src would put a remote script on every page that has a video on it. The frame is sent four commands and answers one, over the protocol the official API itself uses.

The same blocks in the editor

public/js/markdown-editor.js inserts blocks rather than wrapping them.

Button Row Inserts
Note 2 ::: note … :::
Tip 2 ::: tip … :::
! 2 ::: warning … :::
Video 2 ::: video "Title" https://youtu.be/ … :::
Spoiler 2 ::: spoiler "Click to reveal" … :::
Steps 1 ::: steps "Title" recordings/name.rec.json … :::
New 3 ::: added … :::
Changed 3 ::: changed … :::
Fixed 3 ::: fixed … :::
Removed 3 ::: removed … :::
Security 3 ::: security … :::

Two caveats:

  • Video inserts an empty https://youtu.be/ with no prompt. The block is complete and correct, and until an id is pasted into it the preview reads No YouTube video at https://youtu.be/.
  • { } declares block: true in its definition, and that flag is never read anywhere in the module — there is no code path that looks at it. It behaves as an inline wrap.

The caret lands between the fences for every block button, because a block with an empty line in it is a form and that is where the text goes.

Block syntax quick reference

Block Guides Posts Updates Needs a closing ::: Body used? Body discarded? Default title / fallback
::: note yes yes yes yes yes no Note
::: info yes yes yes yes yes no Info / Good to know
::: tip yes yes yes yes yes no Tip
::: warning yes yes yes yes yes no Warning / Careful
::: danger yes literal text literal text — — — Danger
::: success yes literal text literal text — — — Success
::: example yes literal text literal text — — — Example
::: added yes badge, staff only yes yes, auto-closed yes no badge New
::: changed yes badge, staff only yes yes, auto-closed yes no badge Changed
::: fixed yes badge, staff only yes yes, auto-closed yes no badge Fixed
::: removed yes badge, staff only yes yes, auto-closed yes no badge Removed
::: security yes badge, staff only yes yes, auto-closed yes no badge Security
::: spoiler yes yes yes yes yes no Click to reveal
::: tabs / ::: tab yes literal text literal text yes, not auto-closed yes no Tab
::: steps yes table only, published only never no no yes Missing steps / No walkthrough at …
::: video yes yes yes no chapters only yes No YouTube video at …

"Needs a closing :::" means the block works without one on every surface except where the note says otherwise: steps and video are header-only and consume to the end of the text, and tabs has no repair pass.