Version 3.3.0 · All versions

These are the docs as released with Solid Memo 3.3.0. Read the latest docs.

Markdown

Markdown is a simple way to format text with plain characters, such as **strong** for strong emphasis or a dash at the start of a line for a list item. Cards and courses can be written in it. This page says which Markdown Solid Memo reads, which texts may use it, how each place shows it, and why showing it is always safe.

How Solid Memo reads and shows text written in Markdown: the dialect, which texts may be in it, how each place in the app shows it, and why showing it is safe whatever a pod or library holds.

The data side, the sm:textFormat marker and the sm:TextFormats concepts, is in vocab.md. This page is the render policy, which may change without touching data.

The dialect

sm:markdown is CommonMark 0.31.2 with GitHub Flavored Markdown pipe tables and CJK-friendly emphasis, and nothing else: no strikethrough, autolink literals (www.x), task lists or footnotes, which show as typed. Wider Markdown would be a new concept, since it would change how text already written reads.

CJK-friendly emphasis is the markdown-cjk-friendly amendment to CommonMark's flanking rules. CommonMark closes * or ** after punctuation only when a space or punctuation follows, so in Korean, where a particle follows a word directly, **스크립트(script)**라고 and *‘사과’*를 would keep their asterisks. The amendment counts a Chinese, Japanese or Korean character next to the delimiter like a space or punctuation, so both are emphasis, as are **強調。**この文 and its like. For text without those characters it changes one thing: a**"b"**c keeps its asterisks, as in CommonMark, but an emoji or other character outside the Basic Multilingual Plane is now read as one character, where the parser alone saw the two halves of a surrogate pair, neither space nor punctuation. An emoji is a symbol, which CommonMark counts as punctuation, so emphasis next to one now follows the specification: *😀*a and a**😀**b keep their asterisks, as *★*a always did, though the app showed them as emphasis before it took this amendment. _ inside a word still never emphasizes, in Korean as in English: __사람__들 shows as typed, so use **사람**들.

CommonMark has no invalid documents, so nothing is ever refused: what a place cannot show is shown as text.

GFM and the CJK-friendly amendment are not formal standards; standards.md says why they do not stand in the way of reading the text elsewhere.

What is Markdown

Only text whose subject says sm:textFormat sm:markdown. Absent, sm:plainText, or a concept this app does not know: the text is plain, shown exactly as before 1.15.

SubjectMarkdown when markedAlways plain
sm:Cardfront, back, frontNote, backNote, frontLabel, backLabel, and its distractors' distractorText and distractorNotethe pictures' descriptions (their alt text)
sm:Steptheory
sm:Chapterdcterms:descriptiondcterms:title (headings, links, the window title)
deck, release, series, group, catalogevery text: DCAT readers and library search read them

Each language's value is its own document, and its language tag still says what language its prose is in; a code block in @en text stays @en, and a code-only back may be @zxx.

The package

@solid-memo/markdown (packages/markdown/) is the only code that touches the parser, mdast-util-from-markdown with micromark-extension-gfm-table, mdast-util-gfm-table and micromark-extension-cjk-friendly (syntax only: its emphasis is CommonMark's, so it needs no tree extension), and only in src/parse.ts. Everything else sees the package's own tree, already folded to what the app shows:

In the textIn the tree
a headinga paragraph in bold: data never adds to the page's outline
raw HTML, block or inlineits source, as text
a picture, inline or by referenceits alt text: never loaded or linked
a reference linka link to its definition's URL (the first definition of a label wins); definitions themselves show nothing
a code block's info stringits first word, lower-cased, when it matches [a-z0-9+#-]{1,20}; else none
hidden characters in code or link text: every format character (Unicode category Cf, such as the bidi controls U+202A–202E and U+2066–2069, the zero-width characters U+200B–200F, U+2060 and U+FEFF, the soft hyphen U+00AD, the invisible operators U+2061–2064 and the tag characters U+E0000–E007F), and characters that show nothing or look blank yet count (the Hangul fillers U+115F, U+1160, U+3164 and U+FFA0, the combining grapheme joiner U+034F, Khmer's U+17B4–17B5, and the variation selectors U+180B–180F, U+FE00–FE0F and U+E0100–E01EF), (a picture's alt text in a link included)a visible marker such as ⟨U+202E⟩ ("Trojan Source": code that reads other than it runs)
a table body row's cells past the header'sdropped, as GFM has it; a short row is not padded
FunctionModuleWhat it gives
parseMarkdown(text)parsethe blocks: paragraph, code, list, quote, rule, table
parseDocument(text)parsethe repository's own Markdown as blocks, its headings kept at their level, or a DocumentError (repository Markdown)
parseInlineMarkdown(text)inlineone line of phrasing: text, code spans, emphasis, strong
plainText(text)plainTextthe text as plain text
labelText(text, max?)plainTexta short name: the plain text of its first block that shows any
liveLink(url)linkswhether a link is followed, and its host
inspectMarkdown(text)parsewhether the text is one paragraph as written (a heading is not), and notes of what the tree folds away or a check looks at: raw HTML, pictures, links (an autolink told apart), code outside a link, character references, what nests too deep and would not show as written, a table past its caps, a heading underlined with dashes
markdownProblems(text, rule)problemswhat would not show as its author meant, by the rules for a release, in a field held to SIDE, OPTION or PROSE
chunksOf(blocks)chunksthe chunks blocks are shown in, split at their top-level rules
splitAtRules(blocks)chunksthe pieces between top-level rules, empty ones included
inspectChunks(text)chunkshow many chunks a text is shown in, and how many empty pieces its rules make

Limits

Every entry point for data is bounded, for a pod or library may hold anything (repository Markdown lifts only MAX_CHARS):

The package's tests include the cmark and commonmark.js pathological inputs, each at the length cap, within a time budget.

Chunks

A step's theory in Markdown is read a chunk at a time (courses.md), and its thematic breaks (---, *** or ___ on a line of their own) are where one chunk ends and the next begins. Nowhere else does a break split anything: in a note or a chapter's description it shows as a rule.

Where it is shown: the profiles

The renderer is packages/ui/src/ui/Markdown.tsx, reached through DataText.tsx (DataText, DataProse for a step's theory, DataLine); nothing else in the app renders text from data as Markdown. Which profile a text gets depends only on the place, never on the deck or course it comes from.

ConstructBlock profile (DataText, MarkdownBlocks)Inline profile (DataLine)
Text, emphasis, strong, code spansas suchas such
Paragraphs, hard breaks<p>, <br>joined by a space
Lists, block quotes, thematic breaksas such, but for a step's theory, which is split at its top-level breaks (chunks)items joined by "; ", the rest flattened
Code blocks<pre><code translate="no"> in a region that scrolls sideways, its language as a small labelone code span, white space collapsed
Tablesin a region that scrolls sideways, alignment as md-align-* classescells joined by " · "
Linksfollowed only by liveLink (below)their text

Plain text still renders byte for byte as it did before Markdown.

Keys and screen readers

An example

A card's back, with "Format with Markdown" on:

Make a branch with `git switch -c`, then:

- *stage* the files
- **commit** them

```sh
git add .
git commit
```

What a place cannot show is not refused, only shown as typed, with a hint while the card is edited. On a card's side, See <b>this</b> and [the docs](https://example.org). gets three: one for each HTML tag ("HTML such as <b> shows as typed. Put it in backticks to show it as code.") and one for the link ("No links here, where they would be in the way of studying: … Put a link in a note."). In a note, where links are followed, only the two about HTML remain.

Repository Markdown

The repository's own Markdown, its docs, is read by the same package in the same dialect, so a page of the docs reads as the app reads a card. parseDocument(text) is its profile, for trusted input read at build time only, never for data:

Diagrams

The docs' Mermaid blocks are drawn to SVG at build time by the package's node tooling, diagrams.ts and drawDiagrams.ts: the official Mermaid, in Chromium through Playwright, so a drawing comes from the same parser family and layout engine as GitHub's. GitHub runs its own Mermaid version, with HTML labels, so a diagram is still checked on GitHub after an edit (style.md).

A drawing is safe to serve

A drawing is only ever shown through <img>, where nothing in it runs, but its address can be opened on its own, on the app's origin, and GitHub Pages lets the site set no Content Security Policy header. checkSvg does not read a drawing as leniently as a browser would: it takes only what Mermaid writes, and refuses the rest. Every < opens a plain tag of an element Mermaid draws the docs' diagram types with, with no namespace prefix, each attribute one Mermaid writes (or data-…, aria-…, and the SVG and XLink namespaces at their own addresses), each value in quotes with no < inside, and the tags closed in order. So there is no <script>, no event handler, no HTML (<foreignObject>), no animation (which could set a link), no document type, no comment, and no CDATA. A character reference is one of XML's five (&amp; and the like), so the text checked is the text the browser reads. No style or attribute value can load anything (cssProblem): no @import, no image-set(, no backslash (which could spell any of them), no string with : or /, and an href or url( only to #… in the drawing itself. An SVG in <img> loads nothing from elsewhere, so the font is inside it: the one exception is a single @font-face from a data: WOFF2, the embedded font, and only with its notice, the one comment, right after the opening tag.

The diagrams' font

The diagrams are set in Fredoka, the app's own font (vendor-code.md), from @fontsource-variable/fredoka's Latin file. Each drawing embeds it, as one data: @font-face, so it looks the same on every machine and its text fits its boxes: a font of the viewer's own would be measured otherwise. Its glyphs are printable ASCII, Latin-1 but the soft hyphen, and – — ‘ ’ “ ” … −. That is FONT_CHARS, written out and held to the file's own table by a test; the package's unicode-range declares more than the file has, ↑ and ↓ among them. A character outside it would be drawn and measured in another font, so a block that draws one fails, naming it.

Fredoka is under the SIL Open Font License 1.1 (OFL), which lets the font be bundled and embedded in software on these conditions: it is not sold by itself; each copy carries its copyright notice and the license, as a file of its own, a human-readable header, or metadata the user can easily see; a modified version does not use a Reserved Font Name; and the font stays under the OFL, though a document made with it need not. Fredoka names no Reserved Font Name, and the file is embedded unchanged. The file holds its copyright but not the license's text, so each drawing starts with a comment (fontNotice) that quotes the copyright line, the first of the package's LICENSE read at build time; names the license; says the font stays under it and the drawing does not; and points to OFL-Fredoka.txt, the package's LICENSE byte for byte, which goes beside the drawings wherever they are published. checkSvg refuses the font without that comment. Nothing under the OFL is in the repository, so REUSE.toml and LICENSES/ do not name it.

The docs as a site

The docs are also rendered to static pages, by the package's node tooling: renderDocsSite in docsSite.ts reads each page of docs/ with parseDocument, so a page shows as the app would show it, and writes it out as HTML with an emitter of its own, which escapes every text and has no way to write raw HTML. publishDocs.ts is the Vite plugin that emits the pages under docs/ with a build, and in dev renders a page anew on each request, so an edit shows on reload. The site's build (vite.config.ts) uses it, so every build, every release among them, publishes its own docs at /docs/ (deployment.md). It hands the plugin the repository's address from the site's checked settings (VITE_REPO_URL), the commit built, which the links name (or main, with a warning, when it is not known), the version, and the site's origin from CNAME, for each page's canonical address.

What a page holds that would not show as written, or would lead nowhere, fails: renderDocsSite throws one DocsSiteError that lists every problem with its file and line. That is what parseDocument refuses (Repository Markdown); a page that does not open with its one heading of level 1 and a paragraph; a heading whose id the page already has; a task list item ([ ], [x]); a page the README does not link to; a link to a page, heading, file or folder that is not there, out of the repository, from the site's root, or to a scheme other than http: and https:; a diagram's problems (above); and a Mermaid block in a list or quote, which is not drawn. The package's tests render the repository's own docs, so npm run check fails on any of these.

Safety

Pods and libraries are untrusted: anyone's data may be opened, and most of it never passed npm run library:check. Script on solid-memo.com holds the user's Solid-OIDC session. So every safety rule holds when the text is rendered, and none relies on a check of the data.

ThreatRule
Raw HTML, <script>, <img onerror>Text: the tree has no HTML node.
An HTML sinkNone. The renderer turns each node into one fixed element with fixed attributes; data only becomes text children, never markup, a class, a style, an id or a handler. No dangerouslySetInnerHTML, no sanitizer.
javascript:, data:, http:, mailto:, relative and #/route links, a URL with a user name or passwordShown as their text: only an absolute https: URL without credentials is followed (liveLink), so data can neither run script, steer the app, fill in a mail, nor hide its host behind user@. (isLinkableUrl, for the source links decks state, is unchanged.)
Link spoofingA followed link opens in a new tab, says so, and when its text is not its own URL, or its host is an international one, the host it leads to follows it in sight: "the spec (solidproject.org)", punycode kept, so <https://bаnk.example> with a Cyrillic "а" shows "(xn--bnk-6cd.example)". The link and its host are each isolated, so a bidi override in the text before them cannot reorder them: the host, and an address shown as itself, run left to right (dir="ltr"), other link text in the direction of its first letter (dir="auto"), so a link in Arabic reads right to left.
Tracking picturesNever loaded: alt text only. Pictures belong in sm:frontImage/sm:backImage, with a description.
Trojan SourceBidi controls, zero-width and other hidden characters in code and link text shown as markers; links isolated from the text around them.
Denial of serviceThe limits above, at every entry point for data, and each text parsed once (markdownCache.ts keeps the last 500, and a theory's chunks are split from its cached blocks, the plain text of every card of the deck the Studio's workbench searches, and the problems of the last 500 texts of each field rule the Studio's release check reads).

parseDocument lifts MAX_CHARS, so it is never given data: it reads the repository's own files at build time, and no code that reads a pod or library calls it.

Two checks of the built site hold this (apps/web/src/build.test.ts, over every chunk, the Studio's included):

Rules for a release

CommonMark has no invalid documents, and the app shows any text safely, so nothing in a pod is ever refused for its Markdown. A library release is held to more, for text that shows as its author meant: markdownProblems(text, rule) names what would not, and npm run library:check reports it, field by field, for every card, step and chapter of a release that states sm:textFormat sm:markdown (deck-library.md). The card editor shows the same problems as hints, never as a block (writing Markdown in the app).

Problem (code)Why
Raw HTML (html), such as git clone <url>It shows as its source. Write it as code, or escape its <.
A picture (image)It is never shown, only its alt text. A card shows a picture by sm:frontImage/sm:backImage, with a description.
A link where none may be (link): a card's front, back, frontLabel and backLabel and a distractor's distractorText (fields SIDE and OPTION), an autolink <ex:title> or <http://…> includedA link there is in the way of the keys that reveal and grade, and of the <label> an option sits in; an autolink also loses its angle brackets. Write it as code.
A link the app does not follow (linkNotFollowed), in a note, a step's theory or a chapter's description (field PROSE)Only https: without a user name or password is followed; any other shows as text.
Link text that reads as an address or a host name other than the link's (linkHost)Misleading text. The named host must be the link's, give or take a www.: [solidproject.org](https://www.solidproject.org/) is fine, [bank.example](https://evil.example/) is not, and neither is [github.io](https://evil.github.io/), for a domain the host is in says nothing of who runs it. The test is by shape, a dotted word ending in letters, so text that reads as a host name but is none counts too: "Node.js", and a file's name such as package.json, v1.ttl or README.md, even as code. Word it otherwise ("npm's documentation of package.json"). This is a check of the data only: the defense against a link that misleads is the host the app shows beside it (safety).
A bidi control, zero-width or other hidden character in code or a link (hiddenControl)Code that reads other than it runs ("Trojan Source"); the app shows them as markers.
A character reference outside code (characterReference), such as &aring;CommonMark decodes it: it shows as "å". Write it as code, or escape its & (\&aring;). An unknown name, &nosuchname;, is text and passes.
A heading underlined with dashes (dashHeading): a line of text with --- right under it, no blank line betweenIt reads like a line of text over a thematic break, which it would be with a blank line before the ---; in a step's theory that break would end a chunk. Leave the blank line, or write the heading with ##. Underlined with ===, a heading is no such look-alike, and passes.
An option that is not one paragraph (notOneParagraph): a distractor's distractorText, and the back of a card with distractors (field OPTION)Options show on one line, in the inline profile, and must look alike, or the odd one out gives the answer away. A heading is no paragraph: it would show bold on a revealed back.
Past the limits: longer than MAX_CHARS (tooLong), past the parser's others (tooComplex), nested past MAX_DEPTH (tooDeep, unless it is plain text that shows as written anyway), a table past its caps (largeTable)The text, or that part of it, shows as plain text or as its source.

A step's theory is held to its chunks too: none of them empty, and as many in each language, so a learner who switches language keeps their place (deck-library.md).

None of these is a safety rule: the safety rules hold when text is rendered, whether or not a check ran.

Writing Markdown in the app

A user's own card is plain text unless its writer says otherwise: the card editor (CardContentFields.tsx, with MarkdownEditing.tsx) has a "Format with Markdown" checkbox, off for a new card, since Markdown would surprise someone who types 2 * 3 * 4 or <tag>. Every new card starts off, the next one in a run too, though it keeps the added card's languages.

Wrong options, a step's theory and a chapter's description have no in-app editor (courses.md).

Outside readers

Other Solid apps, and schema.org or DCAT readers of a library, see the Markdown source as the literal's value, which is readable by design. A reader with plain CommonMark shows the asterisks of emphasis that only the CJK-friendly rule reads. An app before 1.15 does too (migrations.md).