Last reviewed: October 31, 2026 (Asia/Shanghai).
Direct answer
Render CometAPI chat Markdown by treating the assistant’s text as untrusted input: disable embedded HTML in the Markdown parser, sanitize the generated HTML with a narrow allowlist, and insert the returned DOM fragment. Keep a plain-text fallback using textContent. Passing a response-schema check establishes which string to display; it does not make that string safe to insert as HTML.
The CometAPI chat reference
documents the non-streaming assistant text in choices[].message.content. Markdown rendering is an application choice, rather than a distinct response type. A reply containing a heading, a code fence, or an HTML-looking string still arrives as text.
This implementation uses markdown-it’s documented options to keep source HTML disabled, then DOMPurify’s configuration API to restrict the output. It accepts a small set of text-formatting elements and credential-free HTTPS links. Images, forms, embedded documents, custom elements, styles, and model-supplied event handlers have no place in this reply panel.
Who this is for
This tutorial is for JavaScript developers who already have a browser chat interface and a server-side CometAPI adapter. The adapter owns the API credential, HTTP handling, response-size limits, and completion validation. The browser receives an accepted reply string and decides how to display it.
Use an existing browser build that resolves npm imports. The example uses markdown-it 15.0.3 and DOMPurify 3.4.17, the versions checked here. Install them with npm install --save-exact [email protected] [email protected], retain the lockfile, and review security updates when maintaining the integration. The renderer itself requires no API key or network call.
Key takeaways
- Keep the original reply as text; generate the formatted view from that text.
- Explicitly set
html: falseand disable Markdown images before rendering. - Sanitize after Markdown conversion, using an application-owned element and attribute allowlist.
- Apply the link policy inside the sanitizer pass.
- Insert a DOM fragment directly; avoid reparsing or decorating its HTML afterward.
- Test literal HTML, URL schemes, code fences, invalid input, and fallback behavior with synthetic replies.
Build the reply renderer
Save this as reply-renderer.mjs in your browser source tree:
import MarkdownIt from 'markdown-it';
import createDOMPurify from 'dompurify';
const MAX_REPLY_UNITS = 32_000;
export function createReplyRenderer(browserWindow) {
const md = new MarkdownIt({
html: false,
linkify: false,
typographer: false,
breaks: false,
});
md.disable('image');
const purifier = createDOMPurify(browserWindow);
if (purifier.isSupported) {
purifier.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName !== 'href') return;
try {
const url = new URL(data.attrValue);
if (url.protocol !== 'https:' || url.username || url.password) {
data.keepAttr = false;
} else {
data.attrValue = url.href;
}
} catch {
data.keepAttr = false;
}
});
}
return function renderReply(target, text) {
if (typeof text !== 'string' || !text.trim() ||
text.length > MAX_REPLY_UNITS) {
throw new TypeError('invalid_reply_text');
}
if (!purifier.isSupported) {
target.textContent = text;
return 'text';
}
try {
const fragment = purifier.sanitize(md.render(text), {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 's', 'blockquote',
'ul', 'ol', 'li', 'pre', 'code', 'a',
'h2', 'h3', 'h4', 'hr',
],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
RETURN_DOM_FRAGMENT: true,
});
target.replaceChildren(fragment);
return 'markdown';
} catch {
target.textContent = text;
return 'text';
}
};
}
Create one renderer for the panel and reuse it. Keep its parser and sanitizer private so unrelated components cannot change the policy through shared hooks or configuration.
The markdown-it usage examples
document render() and optional syntax highlighting. This example leaves highlighting and plugins out of the pipeline. Fenced code remains code text, with escaping handled by the parser. Explicit configuration also avoids relying on preset defaults: the current options reference says the CommonMark preset enables HTML by default, whereas this panel always disables it.
DOMPurify’s default scope includes HTML, SVG, and MathML. The explicit ALLOWED_TAGS list narrows this panel to basic formatting. Do not combine it with USE_PROFILES, which the DOMPurify reference says overrides ALLOWED_TAGS. Disabling data and ARIA attributes also keeps model content from supplying application state or accessibility behavior. Put those properties on trusted surrounding components.
The URL hook calls new URL() without a base. Relative paths and scheme-relative destinations therefore lose their href, as do HTTP, mail, script, data, and credential-bearing URLs. Valid HTTPS destinations remain clickable in the current tab because the attribute list excludes target. This is an intentionally restrictive product policy; DOMPurify’s general URL defaults permit more schemes.
An HTTPS link can still be misleading or lead to an unapproved site. If your product permits only particular documentation hosts, add that hostname check inside the same hook. Sanitization does not establish the truth of a citation or the safety of a destination.
Connect the accepted reply
Mount a trusted container, such as <div id="reply"></div>, and initialize the module from your browser entry point:
import { createReplyRenderer } from './reply-renderer.mjs';
const renderReply = createReplyRenderer(window);
const replyElement = document.querySelector('#reply');
// Synthetic completed reply; no provider request is made here.
const mode = renderReply(
replyElement,
'## Result\n\n- **Formatting** is enabled.\n- HTML remains text.',
);
console.log({ render_mode: mode });
At the application boundary, replace that fixture with the completed reply string from your existing adapter. For Chat Completions, validate the documented object, choice, assistant role, finish reason, refusal, and text fields before returning the string. This panel accepts completed text replies. Give refusals, tool requests, and length-limited results their own application states. The guide to reading CometAPI text response objects
covers that earlier boundary.
The 32,000-unit ceiling is a local display setting measured by JavaScript string length in UTF-16 code units. It is neither a token limit nor an HTTP-body limit. Enforce network-byte limits before parsing the API response, then enforce this text limit before Markdown conversion. Invalid input throws before replacing the panel; catch it in the application state handler and show a fixed validation message using textContent.
Use a dedicated content container because replaceChildren() removes its existing children. Keep retry controls, status messages, and labels outside it. For conversation history, allocate a separate container to each message. The chat accessibility tutorial
explains the surrounding log, status, and focus behavior.
Contract details to verify
Run these fixtures against the same renderer and dependency versions used by the application. Inspect the resulting DOM as well as the visible text.
| Fixture | Required observation |
|---|---|
| Heading, emphasis, list, and fenced code | Expected formatting elements appear; code content remains literal. |
| Raw image with an event attribute and a script element | The HTML-looking text is visible, with no live image or script element. |
| Markdown image | No img element appears. |
| HTTPS, HTTP, mail, relative, scheme-relative, credential-bearing, script, and data links | Only the credential-free HTTPS destination retains an href. |
| HTML inside a code fence | A code element contains the literal string; no embedded element is created. |
| Mixed formatting and raw form/style text | Descendant elements and attributes remain within the configured lists. |
| Null, object, blank, and over-limit input | Validation throws before changing existing panel content. |
| Two sequential replies to one panel | The second replaces the first without duplicate nodes. |
| Unsupported sanitizer | The reply is inserted through textContent, with no child elements. |
For a minimal automated check, install jsdom 30.1.3 as a development dependency and save the following as render-check.mjs beside the renderer. Run it with node render-check.mjs on a compatible Node.js version:
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
import { createReplyRenderer } from './reply-renderer.mjs';
const window = new JSDOM('<!doctype html><div id="reply"></div>').window;
const target = window.document.querySelector('#reply');
const renderReply = createReplyRenderer(window);
renderReply(target, '**Good**\n\n<img src=x onerror=alert(1)>');
assert.equal(target.querySelector('strong').textContent, 'Good');
assert.equal(target.querySelector('img'), null);
assert.match(target.textContent, /<img/);
renderReply(target, '[ok](https://example.org) [local](/logout)');
assert.deepEqual(
[...target.querySelectorAll('a[href]')].map(a => a.href),
['https://example.org/'],
);
window.close();
Preparation ran nine fixture groups corresponding to the table in Node.js 24.22.1 with markdown-it 15.0.3, DOMPurify 3.4.17, and jsdom 30.1.3; all passed. These were local DOM checks. No authenticated CometAPI request, browser-engine test, or deployment was performed. Repeat the matrix in supported browsers and check the network panel for unwanted resource requests before release.
Failure modes
Raw markup becomes interactive. Look for a path that sends model text directly to innerHTML, enables parser HTML, or uses another renderer for stored history. Route every assistant-text view through the same boundary. Use textContent for plain previews and error messages.
Sanitized content becomes unsafe later. DOMPurify explicitly warns that later markup modification can undo sanitization. Avoid concatenating HTML, reparsing serialized fragments, or running a library that adds unchecked markup afterward. Any new HTML-producing step belongs before the final sanitizer and needs its own fixtures.
Images or tables disappear. Their elements are outside this panel’s policy. Add features only after defining their rendering, attribute, URL, and resource-loading rules. Widening the allowlist simply to match a screenshot changes the content boundary.
Sanitization is unavailable or throws. The function returns plain text for supported input when the sanitizer is unsupported or conversion fails. Preserve that fallback. Do not substitute unsanitized HTML to keep formatting visible. Module-load failures still need the application’s ordinary initialization error handling.
Streaming fragments produce broken formatting. Keep accumulating source text separately. A simple approach is a plain-text preview during generation, followed by one complete Markdown conversion when the accepted reply finishes. Never append raw chunks to HTML. Preserve your existing request-ownership checks so an obsolete reply cannot replace the current panel.
Sources checked
Four primary references were fetched successfully on October 30, 2026 UTC, which was October 31 in Asia/Shanghai. They support an evergreen rendering task rather than a dated product announcement. The live Markdown references describe version 15.0.3; the live DOMPurify README describes version 3.4.17. Recheck dependency policy when upgrading.
- CometAPI: Create a chat completion — non-streaming text, assistant role, refusal, tool calls, and finish reasons; discovered through the live documentation index.
- markdown-it: MarkdownItOptions — HTML, linkification, typography, line-break, and highlighting options; reached through the official usage documentation.
- markdown-it: Usage examples — package imports, rendering, configuration, plugins, and highlighting; reached through the project’s live documentation navigation.
- DOMPurify: Maintainer README — sanitizer creation, allowlists, attribute hooks, fragment output, support detection, and the warning about changes after sanitization; read from the official repository’s raw document.
FAQ
Is valid JSON enough to render a reply safely?
JSON validation checks the response structure. A valid string can still contain active HTML or unsafe links when interpreted by a browser. Apply the rendering policy after selecting the accepted text.
Why use both HTML-disabled Markdown and a sanitizer?
They control different stages: the parser limits what source syntax can emit, while the sanitizer enforces the panel’s final element, attribute, and link policy. Keep both configurations explicit when dependencies or plugins change.
Can the same module sanitize HTML on a server?
This module is designed for the browser. DOMPurify’s README says server use requires a DOM implementation and recommends an up-to-date jsdom because its behavior affects security. The local fixture environment does not establish a server-rendering contract.
Should formatted HTML be stored as the authoritative reply?
Retain the original text under your normal conversation-retention policy and render it through the current policy when displayed. If you cache formatted output, invalidate that cache when the renderer or sanitizer policy changes.
Reader next step
Add the renderer to one development reply panel, run the fixture matrix, and verify the result in your supported browsers. Then connect the accepted string from the server adapter and make one authorized staging request. Record dependency versions, render mode, and fixture outcomes; keep conversation content and credentials out of routine logs.