@generative-dom/plugin-companion
v0.3.0
Published
Generative DOM plugin — recognise custom-elements-collection ce-* and lesson-* tags inside streamed markdown
Maintainers
Readme
@generative-dom/plugin-companion
Generative DOM plugin that recognises custom-elements-collection ce-* and lesson-* tags inside streamed markdown and renders them as real DOM custom elements.
The plugin is runtime-agnostic: it never imports the component library. You load custom-elements-collection/auto (or tree-shaken subpaths) separately — the browser's custom-element upgrade lifecycle connects the two.
Install
pnpm add @generative-dom/plugin-companion custom-elements-collectionUse
import { GenerativeDom } from "@generative-dom/core";
import { markdownBase } from "@generative-dom/plugin-markdown-base";
import { markdownInline } from "@generative-dom/plugin-markdown-inline";
import { markdownHeading } from "@generative-dom/plugin-markdown-heading";
import { companion } from "@generative-dom/plugin-companion";
// 1) Register the custom elements (all 70 public tags) and ship the theme tokens.
import "custom-elements-collection/auto";
import "custom-elements-collection/tokens.css";
const md = new GenerativeDom({
container: document.getElementById("out")!,
plugins: [
markdownBase(),
markdownInline(),
markdownHeading(),
companion(),
],
});
md.push(`# Release readiness
<ce-kpi value="96.4%" label="Conformance" color="green"></ce-kpi>
<ce-callout type="success" title="Ready to ship">
All quality gates green.
</ce-callout>
`);
md.flush();Prefer tree-shaking? Import only the tags you actually use instead of /auto:
import "custom-elements-collection/kpi";
import "custom-elements-collection/callout";
import "custom-elements-collection/lesson-quiz";
companion({ allowedTags: ["ce-kpi", "ce-callout", "lesson-quiz"] });API
companion(options?: {
allowedTags?: string[];
blockChildren?: string[];
contentRenderers?: Record<string, CompanionContentRenderer>;
}): GenerativeDomPluginallowedTags— override the default whitelist. Defaults to the 70 public tags shipped bycustom-elements-collectionv0.4+ (64ce-*+ 6lesson-*). The 3category: "internal"tags (ce-docs-layout,ce-nav-list,ce-theme-switcher) are excluded by design — they are app-shell layout primitives for CEC's docs site, not content elements meant to appear inside a streamed markdown body. SeeCOMPANION_DEFAULT_TAGSfor the canonical list.blockChildren— list of tags whose children should be parsed as block-level markdown (lists, headings, code fences, blockquotes, tables) instead of inline-only. Defaults to[]— fully backwards compatible. PassCOMPANION_DEFAULT_BLOCK_TAGSfor a curated set of body-bearing layout containers (ce-callout,ce-card,ce-details,ce-feature-card,ce-section,ce-hero,ce-shell,ce-persona,ce-example,ce-verdict,ce-compare,ce-flow,ce-decision-tree,ce-comment,ce-grid,lesson-frame).contentRenderers— per-tag custom child renderer.(content, attrs, ctx) => Node. When present for a tag, short-circuits bothrenderInlineandrenderBlockfor that tag and uses your function instead. Use this to plug a domain-specific renderer into a specific tag — syntax highlighter for<ce-code>, math typesetter for<ce-formula>, mermaid renderer for<ce-diagram>, etc.
Block content inside ce-* containers
Without blockChildren, every tag's body parses inline-only, so a bulleted list inside a callout renders as a single flat paragraph:
<ce-callout type="warn">
- first reason
- second reason
- third reason
</ce-callout>→ <ce-callout type="warn">- first reason - second reason - third reason</ce-callout>
Opt in for the relevant tags (and load @generative-dom/plugin-markdown-list, …-markdown-code, etc. — they must be in the plugin array for block parsing to find anything to match):
import { markdownList } from "@generative-dom/plugin-markdown-list";
import { companion, COMPANION_DEFAULT_BLOCK_TAGS } from "@generative-dom/plugin-companion";
new GenerativeDom({
container,
plugins: [
markdownBase(),
markdownInline(),
markdownList(),
companion({ blockChildren: COMPANION_DEFAULT_BLOCK_TAGS }),
],
});→ <ce-callout type="warn"><ul><li>first reason</li><li>second reason</li><li>third reason</li></ul></ce-callout>
A block-mode container also wraps single-line text in <p> (since paragraphs are a block-level construct). If that breaks your component's vertical rhythm, add a one-line rule to the component's body styles, e.g. :where(p:first-child) { margin-top: 0; } :where(p:last-child) { margin-bottom: 0; }.
Syntax highlighting inside <ce-code lang="…">
<ce-code> doesn't ship a highlighter (no runtime cost when you don't need one). With contentRenderers, plug @generative-dom/plugin-highlight's tokenize + renderTokens exports into the <ce-code> body:
import {
companion,
} from "@generative-dom/plugin-companion";
import {
BUILTIN_LANGS,
tokenize,
renderTokens,
} from "@generative-dom/plugin-highlight";
new GenerativeDom({
container,
plugins: [
markdownBase(),
markdownInline(),
companion({
contentRenderers: {
"ce-code": (content, attrs, ctx) => {
const lang = (attrs.lang ?? "").toLowerCase();
const def = BUILTIN_LANGS[lang];
if (!def) return ctx.createText(content);
const tmp = ctx.createElement("code");
renderTokens(tokenize(content, def), tmp, ctx);
const frag = document.createDocumentFragment();
while (tmp.firstChild) frag.appendChild(tmp.firstChild);
return frag;
},
},
}),
],
});Now <ce-code lang="json">{"k": 42}</ce-code> streams in with <span class="hl-string"> for "k" and <span class="hl-number"> for 42. Style the hl-* classes against the --ce-color-* token palette (see tokens.css). Unknown lang values fall through to plain text — no crash.
The integration stays userland: companion never imports plugin-highlight, plugin-highlight never imports companion. They share only the public RenderContext type from @generative-dom/core.
Default whitelist
Layout & primitives (12): ce-shell, ce-hero, ce-section, ce-grid, ce-card, ce-chip, ce-table, ce-callout, ce-details, ce-toc, ce-abbr, ce-badge
Metrics & charts (9): ce-kpi, ce-progress, ce-bar-chart, ce-chart, ce-plot, ce-heatmap, ce-donut, ce-gauge, ce-sparkline
Comparison & narrative (10): ce-verdict, ce-timeline, ce-compare, ce-flow, ce-decision-tree, ce-example, ce-feature-card, ce-persona, ce-code, ce-filter-bar
Chat surfaces (6): ce-chat-bubble, ce-cursor, ce-thinking, ce-copy-button, ce-tool-call, ce-citation
Forms (6): ce-button, ce-toggle, ce-checkbox, ce-input, ce-textarea, ce-confirm
Feedback (10): ce-feedback-sink, ce-feedback-bar, ce-feedback-summary, ce-feedback-export, ce-feedback-heatmap, ce-bookmark, ce-dismiss, ce-comment, ce-rating, ce-retry-button
Dashboard (6): ce-status-light, ce-skeleton, ce-stat-group, ce-counter, ce-clock, ce-checklist
Content (5): ce-image, ce-file-card, ce-key-value, ce-json, ce-diff
Lesson (6): lesson-frame, lesson-rule, lesson-gap, lesson-quiz, lesson-quickfire, lesson-audio
Excluded by default (CEC category: "internal"): ce-docs-layout, ce-nav-list, ce-theme-switcher
What this plugin does
- Detects
<ce-* … />,<ce-* …>content</ce-*>,<lesson-* … />,<lesson-* …>content</lesson-*>at both block and inline level. - Filters attributes per the same policy as
@generative-dom/plugin-custom-elements: rejectson*event handlers,style, anddata-generative-dom-*. - Renders to real DOM via
ctx.createElement(tag). The browser custom-element registry handles upgrade — register the components before generativeDom renders, or they stay inert until the runtime arrives. - Children inside a content-bearing tag are parsed as inline markdown, so
<ce-card>**bold** body text</ce-card>works end-to-end. - Light DOM is the library default (see ADR-002), which means slotted content produced by generativeDom (text nodes, inline tokens) is visible to the component without a
<slot>indirection.
What this plugin does NOT do
- It does not bundle
custom-elements-collectionor register any custom elements itself. Loadcustom-elements-collection/auto(or individual subpath entries) separately. This keeps the plugin tiny and decoupled from the library's release cadence. - It does not inject the
--ce-*CSS tokens. Includecustom-elements-collection/tokens.css(or a named theme likecustom-elements-collection/dark.css) at page level. - It does not sanitize component internals. Attribute filtering happens at the plugin boundary; what the component does with those attributes is the component's responsibility.
License
MIT
