Backend Markdown Guide
This guide documents the Vona a-markdown module in Cabloy Basic. It is a small server-side rendering and sanitization module, not a content-management product or an HTTP API by itself.
Use this page together with:
What a-markdown provides
The module registers one global Vona bean:
this.bean.markdown.renderHtml(markdown);The public method is:
renderHtml(markdown?: string): stringIts behavior is intentionally narrow:
- execution is synchronous;
- the return type is always
string; - missing, empty, or whitespace-only input returns
''; - non-empty input is parsed, rendered to HTML, and sanitized;
- parsing and rendering errors are not swallowed by a local fallback.
The module itself has no HTTP routes, controllers, DTOs, entities, database tables, migrations, locale resources, runtime configuration, or business persistence policy. A business module decides whether to store Markdown, store a derived HTML projection, return one of those values to each consumer, or delete an empty content record.
For example, Commerce Catalog treats blank Markdown as absent product content and deletes its content record. That is a catalog service decision; renderHtml() only returns an empty string for blank input.
Rendering pipeline
BeanMarkdown.renderHtml() follows this sequence:
Markdown input
→ MarkdownManager.parse(...)
→ Tiptap static renderer
→ sanitize-html with the module policy
→ sanitized HTML stringThe parser and static renderer are initialized once at module load. The configured extension set is:
MarkdownStarterKitTaskList- nested
TaskItem ImageTableKitHighlight
The Markdown manager enables GitHub-flavored Markdown and uses two-space indentation:
const markdownManager = new MarkdownManager({
extensions,
markedOptions: { gfm: true },
indentation: { style: 'space', size: 2 },
});The module therefore supports the configured rich-text structures—such as headings, emphasis, lists, task lists, code, images, and tables—but it should not be documented as preserving arbitrary raw HTML or every feature of a general Markdown product. The extension list and sanitizer allowlist are the current contract.
Sanitization is part of the API contract
The rendered HTML is always passed through a fixed sanitize-html policy. The policy is defined in source and is not a runtime configuration option that callers can replace or extend.
Allowed document structures
The allowlist retains the structures needed by the configured rich-text output:
- document blocks:
p,br,hr,div; - headings:
h1throughh6; - text marks:
em,strong,del,s; - links:
a; - lists and quotes:
ol,ul,li,blockquote; - code:
code,pre; - images:
img; - task-list support:
input,label,span; - tables:
table,thead,tbody,tr,th,td.
Scripts, iframes, style attributes, event-handler attributes, and other non-allowlisted elements are removed rather than preserved as arbitrary HTML.
Allowed attributes and classes
Only the following attribute groups are retained:
- links:
href,rel,target,title; - code:
class; - images:
alt,height,src,title,width; - task inputs:
checked,disabled,type; - task list items:
data-type,data-checked; - task list containers:
data-type; - spans:
class.
Class values are restricted by tag:
- code classes must match
language-*; - span classes must match
hljs-*.
Arbitrary classes and inline CSS do not become part of the output contract.
URL policy
The sanitizer applies scheme checks to href and src:
- links allow
http,https, andmailto; - images allow only
httpandhttps; - protocol-relative URLs are disabled;
- unsupported schemes such as
javascript:do not survive as active link or image attributes.
This is a content-safety rule, not an authorization rule. It does not decide whether a user may publish content, whether an external image is acceptable for privacy or availability reasons, or which CSP a consuming application should deploy.
Transformations
The sanitizer also normalizes selected rendered elements:
- every surviving anchor receives
rel="noopener noreferrer"; - a checkbox input keeps its checked state when present but is always forced to
disabled="disabled"; - a non-checkbox
inputis converted to an attribute-freespan; - a task
likeepsdata-type="taskItem"only when valid, and keepsdata-checkedonly when it is exactlytrueorfalse; - a task
ulkeepsdata-type="taskList"only when valid; - unrelated attributes on task list elements are discarded.
Task checkboxes are therefore presentation elements, not interactive controls.
The authoritative server projection pattern
Commerce Catalog demonstrates the recommended source/projection boundary for a business resource:
- An Admin form submits
descriptionMarkdown. - The product service trims the Markdown.
- The service calls
this.bean.markdown.renderHtml(markdown). - The service persists the Markdown source and generated HTML projection together.
- The Admin DTO returns the editable Markdown to the editor.
- The public detail DTO returns
descriptionHtmlwithout the editable Markdown. - The Web page passes that server-derived HTML to
ZMarkdownHtml.
The core service operation is equivalent to:
const markdown = descriptionMarkdown?.trim();
if (!markdown) {
// The business module decides whether to remove empty content.
return;
}
const descriptionHtml = this.bean.markdown.renderHtml(markdown);
await productContent.save({
descriptionMarkdown: markdown,
descriptionHtml,
});The actual Commerce Catalog service uses a row-locking lookup and either updates or inserts its content record. Its important security property is that it does not trust a caller-supplied descriptionHtml; it derives the value from Markdown on the server.
The integration tests verify that:
- headings, strong text, tables, code, images, and nested task lists are rendered;
- task inputs are disabled;
javascript:links are not retained;- a submitted forged value such as
<script>forged</script>is ignored in favor of newly rendered HTML; - blank Markdown removes the product content record in that business module;
- the public detail exposes HTML while the Admin form receives Markdown.
Every create, update, import, migration/backfill, or other write path that can change Markdown must apply the same server-side projection rule. Rendering only in one Admin endpoint leaves other write paths able to create stale or unsafe derived HTML.
Adopting Markdown in another module
Use this checklist when adding Markdown-backed content:
- Decide whether the resource needs only Markdown or both Markdown and a derived HTML projection.
- Keep Markdown as the editable/source field.
- Call
this.bean.markdown.renderHtml(...)on every authoritative write path. - Define a business policy for blank Markdown; do not assume
renderHtml('')deletes records. - Expose the source and projection separately according to the consumer's authority.
- Use
basic-markdown:formFieldMarkdownfor the Markdown form field. - Use
ZMarkdownHtmlonly for HTML produced by the trusted server rendering path. - Add tests for blank values, invalid URL schemes, task-list output, derived HTML consistency, and forged output fields.
- Treat external-image policy, CSP, authorization, and content lifecycle cleanup as application concerns beyond this renderer.
The matching frontend component and SSR behavior are documented in Frontend Markdown Guide.