@wix/htmdx
v4.11.1
Published
Render editable HTMDX source inside plain HTML artifacts.
Downloads
4,356
Readme
@wix/htmdx
Render editable MDX-like source inside a plain HTML file. @wix/htmdx v4 uses one component definition model for Built-ins, shadcn, and host extensions. HTMDX is built for artifacts that should be easy for people to view and easy for agents to edit.
Live examples: examples index · decision brief · blank canvas · component tour · Storybook — every page is itself an htmdx artifact; view source to see what an agent edits.
Start with one HTML file:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/@wix/[email protected]/dist/browser.js" defer></script>
</head>
<body>
<!-- prettier-ignore -->
<script
type="text/htmdx"
data-htmdx-edit-instruction="Edit only this script content. HTMDX format."
>
# Title
<ExecutiveSummary>
Agents edit source. Users view rendered HTML.
</ExecutiveSummary>
</script>
</body>
</html>The runtime auto-mounts each bare source block: it wraps the script in a generated <htmdx-code> host in place and renders there. Write <htmdx-code> yourself only when you need explicit output placement or src; disable scanning with register({ automount: false }).
Source block notes:
<script type="text/htmdx">is the canonical source holder. Browsers store its content as raw text, so component tag casing, code fences containing HTML, and angle brackets in prose survive byte-for-byte, and HTML formatters leave the content alone.<template type="text/htmdx">is also supported, but its content is HTML-parsed and re-serialized, which can rewrite the source (lowercased component tags, restructured code fences).- A literal
</script>inside the source ends the block early; keep such examples in an externalsrcfile instead.
CDN caveats:
- Generated artifacts can load the browser bundle from jsDelivr after the package is published to npm.
- Pin an explicit package version in generated artifacts. Do not use floating aliases like
@latest, because saved HTML artifacts must keep rendering the same runtime over time.
Exact-version component manifest
Every release includes its machine-readable component contract at:
https://cdn.jsdelivr.net/npm/@wix/[email protected]/dist/components.jsonUse the same exact version as the artifact's runtime URL. The htmdx@2
manifest lists the full runtime catalog — Built-ins plus the shadcn/ui pack,
each entry tagged with its source, body mode, prop schema, and canonical
example. It projects this data from the same definitions used by the runtime;
the executable Component field does not appear in JSON.
Each manifest entry declares body: "markdown" | "htmdx" | "none".
markdown passes raw Markdown to the component and rejects nested tags;
Built-ins use this mode. htmdx accepts Markdown, HTML, and nested registered
component tags; shadcn and external definitions use it when they support
composition. none accepts only an empty or self-closing tag. A Built-in's
purpose and example
describe any stricter list or table grammar it checks; npx @wix/htmdx skill
components prints the same grammar per family in prose, with a worked example
per family. Invalid bodies fail the
whole compile, and browser hosts show the error with the raw source. Imports,
exports, brace expressions, event handlers, and function-valued props cannot
be expressed — the source is data, not code.
Raw HTML
Ordinary HTML renders alongside Markdown, from an allowlist:
Watch <a href="https://wix.com">the announcement</a> or play it here.
<video controls width="640" poster="poster.png">
<source src="clip.webm" type="video/webm">
</video>
<iframe src="https://example.com/embed" width="560" height="315" allowfullscreen></iframe>
<div class="grid gap-4">
## Still Markdown in here
<Badge>Shipped</Badge>
</div>Structural, text-level, table, and media elements are allowed — p, div,
section, figure, details, table, a, span, br, video, audio,
iframe, and friends. Anything outside the list (form, input, object,
svg, unknown tags) is not markup at the top level, so it stays literal text
the way it always has.
Inside a component body, a tag outside the list still renders the way it did
before the allowlist existed — it is passed through with its attributes — so
documents written against the old behavior keep compiling. The exception is the
handful of elements that turn source into code (script, style, link,
meta, base, embed, object, template), which fail the compile.
A registered component still wins on name collision, as before: with the shadcn
pack registered, <table> and <button> resolve to Table and Button.
A block element that opens a line owns everything up to its close tag, so blank lines, Markdown, and nested component tags inside it keep working. HTML written mid-sentence renders inline. HTML inside code fences and code spans stays literal, as before.
Only allowlisted attributes survive: global ones (class, id, title,
lang, dir, role, style, tabindex, hidden), aria-*, data-*, and a
per-element set. href, src, cite, poster, and srcset are scheme
checked the same way Markdown links are; style is parsed into a React style
object with url() values checked and expression() dropped; on* attributes
fail the compile; iframe cannot set srcdoc. The source still cannot express
code, only data.
Inline SVG
Vector graphics are written in the source, so a chart, diagram, or icon needs no image file and no component:
<svg viewBox="0 0 320 120" width="320" height="120" role="img">
<title>Quarterly revenue</title>
<defs>
<linearGradient id="bars" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6366f1"></stop>
<stop offset="1" stop-color="#a855f7"></stop>
</linearGradient>
</defs>
<rect x="16" y="70" width="48" height="40" rx="4" fill="url(#bars)"></rect>
<text x="16" y="12" font-size="11">
Revenue by quarter
</text>
</svg>SVG answers to its own allowlist, separate from the HTML one, because it has its
own element and attribute space. Shapes, g, defs, gradients, pattern,
clipPath, mask, marker, symbol, switch, text/tspan/textPath, and
the filter primitives are allowed. Element names keep their casing, so
<linearGradient> and <clipPath> work as written — and <lineargradient> is
corrected to match.
A graphic can move on its own. <animateTransform> and <animateMotion> (with
<mpath>) are allowed, so a spinner or a marker travelling a path needs no
script and no CSS:
<svg viewBox="0 0 40 40" width="40" height="40">
<rect x="10" y="10" width="20" height="20" fill="#6366f1">
<animateTransform
attributeName="transform"
type="rotate"
from="0 20 20"
to="360 20 20"
dur="4s"
repeatCount="indefinite"
></animateTransform>
</rect>
</svg>attributeName is pinned to transform, gradientTransform, and
patternTransform — the only three those elements need. Any other target is
dropped, so an animation cannot rewrite an href after the compile has checked
it. <animate> and <set> take a free-form attributeName by design, which is
why they are not allowed at all.
Left out on purpose: <script>, <foreignObject>, <use>, <image>,
<feImage>, <a>, <animate>, and <set>. Each is a way to reach out of the
graphic — into script, into HTML, or into another document. Every filter
primitive that only computes from its inputs is allowed; <feImage> is the one
that loads a document, so it is the one that is not. Inside an <svg> they
render as the text they were written as, the same way a non-allowlisted HTML tag
does.
References stay inside the document. fill="url(#bars)" resolves against the
graphic, while fill="url(https://example.com/x)" is dropped, and href is
accepted only on textPath and mpath, and only as a #fragment. on*
attributes fail the compile and style is sanitized the same way it is in HTML.
Text inside <text> and <tspan> renders as written — Markdown does not apply
there. An SVG element written outside a graphic, like a bare <path> in prose,
stays literal text. Inside an <svg>, SVG wins over a registered component of
the same name, and a component tag written there fails the compile rather than
rendering into the graphic.
Images can use Markdown or HTML syntax:

<img src="screenshots/result.png" alt="Build result" width="960" loading="lazy">Relative, http:, https:, and supported data:image/* sources are accepted.
HTML images allow alt, title, width, height, loading, decoding, and
class; event handlers and unsafe URL schemes are dropped.
Use src when the source should live next to the HTML, in either form:
<script type="text/htmdx" src="./artifact.mdx"></script>
<htmdx-code src="./artifact.mdx"></htmdx-code>Module API:
import { compile, compileDocument, register } from '@wix/htmdx';
register();
const rendered = compile('# Title');Full-document layouts are selected by frontmatter or host options. Host options win:
---
layout: blank
---
# Source-order canvascompile(source, { layout: 'blank' });
register({ layout: 'blank' });
compileDocument(source, { layout: 'blank' });Omitting layout uses default, preserving the existing document chrome and automatic ## section grouping. creator-kit is a built-in alias for default, so an artifact can pin the chrome it was authored against by name. blank omits the hero, sticky header, navigation, and grouping while retaining the stable root, catalog, theme, and Tailwind. Htmdx and compileToReact() remain content-only React entrypoints; use compileDocument(source).element for the selected full-document layout.
Trusted hosts register custom React layouts with explicit frontmatter-backed slots:
window.Htmdx.registerLayout({
name: 'decision',
slots: {
eyebrow: { from: 'project' },
byline: { from: 'owner' },
status: { from: 'phase' },
},
Component: ({ children, slots }) =>
window.Htmdx.React.createElement('main', null, slots.eyebrow, children),
});The slots record contains only declared keys; missing fields resolve to undefined, and raw frontmatter is not passed. Names collide case-insensitively, built-in names cannot be replaced, and unknown selected names fail clearly.
Extension API. Trusted host code can contribute React components and theme CSS from an inline or external script:
<script
src="https://cdn.jsdelivr.net/npm/@wix/htmdx@<exact-version>/dist/browser.js"
defer
></script>
<script>
window.addEventListener('htmdx:ready', () => {
const { createElement } = window.Htmdx.React;
window.Htmdx.registerComponent({
name: 'ProductCard',
purpose: 'Group product details in a card.',
example: '<ProductCard>Product details.</ProductCard>',
body: 'htmdx',
Component: (props) => createElement('aside', { className: 'product-card' }, props.children),
});
window.Htmdx.registerTheme({
id: 'product',
css: `.product-card { border: 1px solid var(--border); padding: 16px; }`,
});
});
</script>Extension code is host-owned and explicit. The HTMDX source remains declarative; unknown capitalized tags fail compilation until registered.
Tailwind utilities work in registered components by default — the runtime
injects Tailwind's browser compiler, so className values compile on the fly.
The runtime injects Tailwind's browser compiler before rendering hosts:
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" defer></script>Hosts can disable it or point at a local mirror:
window.Htmdx.register({ tailwind: false });
window.Htmdx.register({ tailwind: { src: './tailwind-browser.js' } });Use the browser compiler for portable artifacts and prototypes. Production hosts that need a compiled CSS pipeline can disable it and provide their own CSS with registerTheme.
Diagrams
A fenced code block tagged mermaid renders as a diagram:
```mermaid
flowchart LR
Source --> Runtime --> Diagram
```Mermaid is not bundled — it is several times the size of the whole runtime — so
the first diagram on a page fetches it from a CDN, the same trade the Tailwind
compiler makes. compile() stays synchronous: it emits the fence, and the
browser upgrades it in place. An artifact that never reaches a browser, or one
where the fetch fails, still shows the diagram source.
Hosts can disable it or point at a local mirror:
window.Htmdx.register({ mermaid: false });
window.Htmdx.register({ mermaid: { src: './mermaid.esm.min.mjs' } });The rendered SVG does not go into the DOM as markup. It is re-parsed and vetted
against the same allowlist authored SVG passes, so <foreignObject>,
<script>, on*, and references to other documents cannot reach the page no
matter what the library emits. Mermaid runs at securityLevel: 'strict' with
htmlLabels off, and those keys are locked against an in-source
%%{init: ...}%% directive, so a click directive in a diagram binds nothing.
Mermaid's own stylesheet is lifted out of the graphic and filtered the same way
a style attribute is. A diagram that fails any of this keeps its fence text on
the page and says why in the console.
React runtime (MDX minus JavaScript)
htmdx renders through React everywhere: Built-ins and the shadcn/ui pack are bundled as complete component definitions. Global registration and per-render extensions accept the same definitions, and names cannot replace bundled or registered definitions. The source stays declarative data: component tags, nested composition, and declared attribute props work; imports, exports, brace expressions, event handlers, and function props are rejected by design.
The standard runtime script gives an artifact the full catalog:
<script
src="https://cdn.jsdelivr.net/npm/@wix/htmdx@<exact-version>/dist/browser.js"
defer
></script>
<!-- prettier-ignore -->
<script type="text/htmdx">
# Q3 Report
<Card class="max-w-xl">
<CardHeader>
<CardTitle>Revenue</CardTitle>
<CardDescription>Audited quarterly numbers</CardDescription>
</CardHeader>
<CardContent>
Revenue grew **12%** quarter over quarter.
<Badge variant="secondary">audited</Badge>
</CardContent>
</Card>
<Tabs defaultValue="summary">
<TabsList>
<TabsTrigger value="summary">Summary</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="summary">Topline numbers.</TabsContent>
<TabsContent value="details">Full cost breakdown.</TabsContent>
</Tabs>
</script>dist/browser.js bundles React, the built-in catalog (ExecutiveSummary,
MetricStrip, charts, ...), the shadcn/ui pack (Card, Badge, Button, Tabs,
Accordion), and the shadcn theme, including the static-render path that powers
compile(). It stays under 160KB gzip: the build measures the written bundle
against
build/bundle-budget.json
and fails when it grows past the ceiling, so the figure cannot drift without a
reviewed change to the budget.
Authoring htmdx source instead of rendered markup is measurably cheaper for
agents: the full single-file artifact is about 4.3x smaller in tokens than the
same artifact as compiled HTML, and 2-3x smaller than hand-written
HTML+Tailwind, with edits cheaper in the same range. Reproducible benchmark in
bench/RESULTS.md
(yarn bench).
React host apps use the module entries instead (react/react-dom are optional peer dependencies):
import { Htmdx } from '@wix/htmdx/react';
import type { HtmdxComponent } from '@wix/htmdx/components';
import * as builtins from '@wix/htmdx/components/builtins';
import * as shadcn from '@wix/htmdx/components/shadcn';
const MyChart = {
name: 'MyChart',
purpose: 'Show a custom chart.',
example: '<MyChart>Quarterly results.</MyChart>',
body: 'htmdx',
Component: MyChartView,
} satisfies HtmdxComponent;
<Htmdx
source={artifactSource}
definitions={[...Object.values(builtins), ...Object.values(shadcn), MyChart]}
/>;Definitions are available from @wix/htmdx/components,
@wix/htmdx/components/builtins, and @wix/htmdx/components/shadcn.
Component-specific attributes form an allowlist and parse by their declared
string, number, boolean, or json type. Every component also accepts
class, id, aria-*, and data-*. Well-formed HTMDX bodies are parsed as
XML, so camelCase names such as defaultValue stay intact; malformed bodies
fall back to HTML parsing.
Security note: the React runtime runs the registered component code with
agent-authored props (compile() can still emit a static HTML snapshot of the
same tree). Components are host-owned and whitelisted; the source still cannot
express code, only data.
Validating source
compile() stops at the first failure. validate() reports every independent
problem at once, each anchored to a position in the source:
import { validate } from '@wix/htmdx';
for (const { line, column, severity, code, message } of validate(source)) {
console.log(`${line}:${column} ${severity} ${code} — ${message}`);
}
// 3:1 error unknown-component — unknown component <Nope>
// 5:10 error unknown-prop — unknown prop "tone" for <Callout>
// 9:1 warning image-missing-alt — image has no alt textPositions are 1-based line/column plus a 0-based offset and length, so
editors and language servers can underline the exact span. An empty array means
the source is clean. Like compile(), this needs a DOM (a browser or jsdom).
To run these checks over files from a terminal or CI, see
htmdx lint, which adds the findings that only apply to a whole
artifact.
Command line
This package ships an htmdx bin, so npx runs it without an install. Every
command answers from the runtime doing the answering, so pinning the invocation
to the version an artifact declares gets you the behavior that artifact ships:
npx @wix/[email protected] lint docs/*.htmdx --strict
npx @wix/[email protected] compile report.htmdx --out report-body.html
npx @wix/[email protected] components Callout
npx @wix/[email protected] skill| Command | Description |
| ------------------- | --------------------------------------------------------- |
| lint <files...> | Report problems. validate is an alias for the same run. |
| compile <file> | Print the htmdx-app markup. |
| components [name] | List the catalog, or describe one component. |
| skill [topic] | Print the authoring guidance shipped with this runtime. |
Exit codes are 0 clean, 1 problems found, and 2 could not run.
htmdx lint
Runs everything validate() reports, plus two findings that only exist once
source is embedded in a page: unpinned-runtime (the runtime <script> has no
pinned version, so a future release can change the artifact) and
runtime-version-mismatch (the artifact pins a version other than the one
linting it). That is why the command is lint rather than validate — it is a
superset of the API call, checked against a whole file rather than a string.
validate is accepted as an alias for anyone who reaches for that name first.
| Option | Description |
| ------------------------- | -------------------------------- |
| --format <pretty\|json> | Output format. Default pretty. |
| --strict | Treat warnings as failures. |
It accepts an HTML artifact — the source comes from its
<script type="text/htmdx"> block and positions are reported against the
artifact — or a bare source file.
invalid-html-nesting comes from React, which remembers which nesting warnings
it has already logged in module state no API resets. Linting many files in one
run reports each distinct violation once, on the first file that has it; lint a
file on its own to see all of them.
htmdx compile
Prints what compile() returns to a JS caller, for pipelines that want the
markup without running the runtime in a browser: a build step, a readable diff
in review, or a server that renders once and serves the result.
| Option | Description |
| ------------------ | ------------------------------------------- |
| -o, --out <file> | Write to a file instead of stdout. |
| --layout <name> | Document layout, same names as frontmatter. |
It reads a source file or an artifact, the same way lint does. A source the
runtime rejects exits 1 with the compile error on stderr.
The output is the htmdx-app markup, not a standalone page — it carries the
class names but not the theme, so serving it still means loading the styles the
browser bundle injects.
htmdx components
Prints the component manifest built next to the bin, so the catalog is the one that version renders. With no argument it lists every component grouped by source; with a name it prints that component's purpose, body mode, props, and canonical example.
$ npx @wix/htmdx components Foldout
Foldout
A collapsible panel: a titled header that expands on click to reveal flexible content (text, tables, charts, or any nested component). Collapsed by default; stack multiple for a group.
body: htmdx source: built-in
props:
title: string
The header text shown in the summary row. Supports inline markdown.
open: boolean (default false)
Render the panel expanded on load. Defaults to collapsed.
example:
<Foldout title="Additional details">
Any content — text, a table, a chart, or any nested component.
</Foldout>A name that does not match exits 1 and suggests the closest entries, by
substring and by edit distance, so a typo or a half-remembered name still lands:
unknown component "Calout"; did you mean Callout?. --format json prints the
manifest entry, or the whole manifest when no name is given — the shape to read
before writing a document rather than guessing at prop names.
Agent guidance
The same bin prints the authoring guidance that ships with this runtime, so an agent reads the contract, component grammar, and verification steps for the exact version an artifact pins instead of a copy that drifts:
npx @wix/[email protected] skill # authoring guidance
npx @wix/[email protected] skill --list # available topics
npx @wix/[email protected] skill components # body grammar per component
npx @wix/[email protected] skill --full # every topic in one stream
npx @wix/[email protected] skill starter > brief.html--json returns { runtime, topics: [{ name, description, content }] }. An
unknown topic exits 2 and names the valid ones. The topic files live in
skill/ in this package.
Testing documents
@wix/htmdx/testing covers the two things a consumer's test suite needs: get
the source out of a shipped artifact, and snapshot it.
import { extractSource, snapshot } from '@wix/htmdx/testing';
const source = extractSource(readFileSync('report.html', 'utf8'));
expect(snapshot(source)).toMatchInlineSnapshot(`
markdown "# Report"
<Callout>
text "Ship it."
`);snapshot() defaults to mode: 'structure' — the component tree as written,
so upgrading the runtime does not churn every snapshot. Pass mode: 'html' to
snapshot the rendered markup instead. Either mode throws if the source has
errors, rather than recording the breakage as expected output.
