Variables and Markdown
The SDK fills {{ variables }} that content managers write into their text, and turns a Markdown body into sanitised HTML whose images point where your site serves them and whose headings carry the anchors search links to.
Why the site fills names and renders the Markdown
Some words on a page are not the content manager's to write: the reader's name, a price from a shop system, the current year. So a content manager writes {{ user_name }} where the name goes, and the site supplies the value when it reads the page. The sentence around the name stays editable in the CMS. Markdown is the other half of the job. The CMS stores it as plain text, and the site has to turn it into HTML. That HTML must not let a stray <script> through, its images must resolve against the right host, and its heading ids must match the ones search hands back.
If you are a content manager, a variable is two pairs of braces around a name your engineers gave you, such as {{ user_name }}. If the site does not supply that name, readers may see the braces exactly as you typed them. Ask which names exist before you use one.
What gets filled in and what the renderer emits
Variables
interpolate(text, vars) replaces each {{ name }} or {{ a.b }} with the value at that name or path. A name is made of letters, digits and underscores. A path joins names with dots. Spaces inside the braces are allowed. Nothing else counts as a variable, so there are no filters, conditions or loops. \{{ is a literal {{. A value may be a string, a number or a boolean.
The missing option decides what an unknown name becomes:
keep, the default: the placeholder stays exactly as written.empty: the placeholder is removed.throw: the call throws aMissingVariableError, whosevariable,keyandpathsay which name was missing, in which document, at which field.
get and walk take vars and missing and apply them to the whole body. In a structured document that means every string at any depth; in a Markdown or text document it means the whole text. They never touch _meta or the fields of an inlined file. A value goes in exactly as you pass it, into rich-text HTML fields as much as anywhere else, so escape anything a reader can influence before you pass it.
Markdown
renderMarkdown(markdown, options) from @quilletcms/sdk renders GitHub-flavoured Markdown (tables, task lists, strikethrough, autolinks) to HTML. It uses the same pipeline the dashboard previews with. Three options change what it does:
headingIds: trueputs anidon every top-levelh2andh3. The id isheadingSlugof the heading's text. The CMS uses that same function when it indexes a page for search, so a search hit'sanchoris always an id the page has. A repeated heading gets-1,-2and so on, which is whatheadingSlugscomputes for a list of headings. Headings inside a blockquote or a list get no id, and neither does anh1. Ids are off by default.resolveAssetUrldecides what an image or link written as/d/v1/<project>/assets/<id>becomes. It receives{ id, path }and returns the URL to use. Any?w=or?f=on the original is kept. Absolute URLs and every other path are left as written.allowHtml: truekeeps raw HTML from the source. It turns sanitising off entirely, script tags included.
q.renderMarkdown(markdown, options) on a client is the same function, with resolveAssetUrl already set from the client's assets configuration. With assets.proxyPublic, the path becomes your own /media/<id>. Without it, the path stays the same but points at the CMS the client reads. An option you pass yourself wins over the preset one.
The CMS refuses a private file in a Markdown body when the document is saved. Every file reference here is therefore public, and nothing needs signing.
How to
Fill a sentence, and decide what an unknown name does. In production, keep shows the braces to readers; in a test, throw finds them before anyone else does:
// greet.ts: two variables, one of them missing.
import { MissingVariableError, interpolate } from '@quilletcms/sdk'
const text = 'Hello {{ user.name }}, your plan is {{ plan }}.'
console.log(interpolate(text, { user: { name: 'Ada' } }))
console.log(interpolate(text, { user: { name: 'Ada' } }, { missing: 'empty' }))
try {
interpolate(text, { user: { name: 'Ada' } }, { missing: 'throw' })
} catch (error) {
if (error instanceof MissingVariableError) console.log('not supplied:', error.variable)
}
Supply variables as you read, and escape whatever came from the reader. In this example a name typed into a form goes into a Markdown body, and that body becomes HTML:
// welcome.ts: a value from outside, escaped before it becomes a variable.
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const escape = (text: string) => text.replace(/[&<>"'*_`[\]\\]/g, (c) => `&#${c.charCodeAt(0)};`)
const typed = 'Ada <b>Lovelace</b>'
const intro = await q.get<string>('docs/intro', { vars: { user_name: escape(typed) } })
console.log(await q.renderMarkdown(intro.content))
Render a page with heading ids, and build its table of contents from the same slugs, so that every link lands on its heading:
// toc.ts: the body with anchors, and a contents list that matches them.
import { headingSlugs } from '@quilletcms/sdk'
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const doc = await q.get<string>('docs/intro')
const html = await q.renderMarkdown(doc.content, { headingIds: true })
const titles = [...doc.content.matchAll(/^#{2,3} +(.+?) *#*$/gm)].map((m) => m[1] ?? '')
const ids = headingSlugs(titles)
const toc = titles.map((title, i) => `<li><a href="#${ids[i]}">${title}</a></li>`).join('\n')
console.log(`<ul>\n${toc}\n</ul>\n${html}`)
The regular expression above reads plain ATX headings, which is what most bodies hold. A heading with formatting or a footnote in it is slugged from its text as the CMS reads that text. For headings like those, take the ids from the rendered HTML instead.
If a CDN sits in front of the CMS, you can point Markdown images at the CDN rather than at the CMS itself:
// cdn.ts: every image in a body served from the site's CDN.
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const doc = await q.get<string>('docs/intro')
const html = await q.renderMarkdown(doc.content, {
resolveAssetUrl: ({ id }) => `https://images.example.com/${q.config.project}/${id}`,
})
console.log(html)
For Markdown that did not come from a read, such as a body under test, use the renderer without a client:
// render.ts: the standalone renderer, ids on.
import { headingSlug, renderMarkdown } from '@quilletcms/sdk'
const html = await renderMarkdown('## Promoting an Environment\n\nText with <script>alert(1)</script>.', {
headingIds: true,
})
console.log(headingSlug('Promoting an Environment'))
console.log(html)
It prints promoting-an-environment, then the heading with that id, and then <p>Text with alert(1).</p>. The sanitiser drops the <script> tags, and the text that was between them is left behind as harmless words.
Limits
- Variables are names and dotted paths only. There are no defaults, filters, conditions or loops.
- The CMS does not fill variables; only the SDK does. Search snippets, and anything else that reads the stored text, show
{{ user_name }}as written. - A variable's value is not escaped. Whatever you pass is inserted into every string of the body, HTML fields included.
renderMarkdownemits no heading ids unless you ask for them, and then only for top-levelh2andh3.allowHtml: trueturns off all sanitising. Use it only for content where you trust every editor.- The renderer handles GitHub-flavoured Markdown and nothing more: no syntax highlighting, no callouts, no directives. When you need those, run your own pipeline over the stored Markdown. This site does, and Static sites shows how its heading ids still match.
- Markdown cannot hold a private file. The CMS refuses one when the document is saved.
Related
- Markdown and assets: how the CMS stores image paths in a body, and
_meta.assets. - Writing a document: the dashboard's Markdown editor.
- Search: the anchors
headingIdsrenders. - Assets: files in structured fields, private ones included.
- Reading content:
getandwalk, which takevars.