htmlify runtime — how an answer becomes an artifact
Module map of the htmlify Node runtime: a dependency-layered core under src/, one facade, three entry surfaces (CLI, Claude Code hook, Pi/OMP extension), and a validator that gates every model-generated document before it touches disk.
Module dependency graph
Layering is strict and acyclic: constants and text have no internal dependencies; markdown, document, annotation, and validate build on them; artifacts composes all four to write files; src/extension/index.js is the only stateful module and the only one that talks to a host. bin/htmlify-answer.js and hooks/claude-code-stop-htmlify.js never import core modules directly — both go through the index.js facade's _internals.
Long answer to opened artifact, in eight calls
message_end, normalizes loose host event shapes (message / entry / payload / data), keeps only assistant-role text, returns { id, role, text } with a SHA-1 fallback id.state.lastEligible; a custom session entry (html-long-answer-source, sans text) persists it across reloads. isLongAnswer gates at 1,800 chars / 24 lines / 6 paragraphs.parseHtmlCommandInput matches /htmlify, /html-last, /htmlify-last; resolveForcedExportMode maps args to choose / rich-gemini / rich-pi / local. Default path is the local render.escapeHtml first.HTMLIFY_EXPORT_ROOT → legacy PI_HTML_LONG_ANSWER_EXPORT_ROOT → os tmpdir), builds a timestamp-slug-mode filename, and stamps the answer's SHA-1 as sourceId so later comments can be matched to it.data-commentable / data-block-id="b-N", then appends the trusted annotation layer before </body>: a localStorage-backed comment panel keyed by sourceId, exporting Markdown or JSON for /htmlify-comments. Guarded by a marker comment so it is never injected twice.openArtifact resolves /usr/bin/open (macOS) or xdg-open (Linux) from PATH, spawns it detached, and treats survival past a 1,000 ms failure window as success. HTMLIFY_SKIP_OPEN=1 suppresses it.The rich variant forks at step 03: queueRichExport either shells out to the Gemini CLI or sends buildRichHtmlPrompt back to the host as a follow-up turn, then maybeHandlePendingRichExport catches the next assistant message, extracts the fenced HTML, and routes it through the validation path below — falling back to the local render if the document is unsafe or plain text.
Two doors, one set of collectors
Runtime gate — model HTML
extractHtmlDocument(messages.js) pulls a fenced```htmlblock or bare document out of the model's reply.writeRichHtmlArtifact(artifacts.js) callsvalidateRichHtmlDocument(validate.js:216), which runs the rich collector and throws on the first error — nothing invalid is ever written.- On throw, the extension notifies and writes an
llm-enhanced-fallbacklocal render instead, so the user always gets a file.
What the rich collector rejects
- Blocked tags: script, iframe, object, embed, link, base, and all form controls (
BLOCKED_RICH_TAGS). - Event-handler attributes and
javascript:-scheme URLs. - External assets in markup or CSS (
EXTERNAL_ASSET_ATTR,EXTERNAL_CSS_URL), meta refresh. - Size: over 512 KB or 2,500 tags; not a standalone document. Missing doctype and missing local assets are warnings.
CLI gate — --validate
bin/htmlify-answer.jsvalidates files against a profile;detectProfile(validate.js:247) sniffs each document when set toauto: slides + speaker notes →deck, any inline script →app, otherwiserich.appprofile (allowInlineScript: true) permits inline scripts and form controls for editors/prototypes, but still bans external scripts, embeds, event-handler attributes, and non-data:link hrefs.deckprofile adds the deckify contract viacollectDeckIssues: non-empty title, viewport meta, ≥2 slide sections, a keydown listener for navigation, speaker notes on every substantive slide, 2 MB budget; missing print CSS is a warning.- Exit codes: 0 valid · 1 validation errors · 2 usage/IO error.
--format jsonemits machine-readable reports.
node bin/htmlify-answer.js --validate examples/htmlify/implementation-map.html --profile rich # examples/htmlify/implementation-map.html: valid — 0 errors, 0 warnings (profile: rich)
Seventeen files, one job each
| Path | LOC | Responsibility | Key exports |
|---|---|---|---|
| index.js | 45 | Public facade: the extension factory is the default export; every internal symbol is re-exported under _internals for the CLI, hook, and tests. | createExtension · _internals |
| src/constants.js | 72 | All tunables and safety regexes in one place: long-answer thresholds, size budgets, blocked-tag patterns, session entry types, the trusted-annotation marker. | LONG_ANSWER_DEFAULTS · BLOCKED_RICH_TAGS · MAX_RICH_HTML_CHARS |
| src/text.js | 79 | Dependency-free primitives: SHA-1 ids, HTML escaping, slugs, paragraph/line/word counts. | sha · escapeHtml · slugify · wordCount |
| src/markdown.js | 164 | Markdownish-to-HTML renderer for the local export path: fences, headings, callouts, pipe tables, lists, inline formatting. | renderMarkdownish · formatInline |
| src/document.js | 356 | The Hardcopy document shell: plate, crop marks, index rail, carbon wells, dark mode, print CSS; plus title/excerpt/outline derivation. | buildLocalHtmlDocument · deriveTitle · buildOutlineHtml |
| src/validate.js | 265 | Issue collectors and throwing validators for the rich/app/deck profiles, plus shape-sniffing profile detection and local-asset checks. | collectRichHtmlIssues · collectDeckIssues · validateRichHtmlDocument · detectProfile |
| src/annotation.js | 172 | Review layer: marks blocks commentable and injects the localStorage comment panel (the one place trusted inline script is generated). | addCommentableAttributes · injectAnnotationLayer |
| src/comments.js | 75 | Round trip for reviewer comments: validates a downloaded JSON bundle against the captured sourceId and renders it as an agent prompt. | validateCommentBundle · buildCommentsPrompt |
| src/artifacts.js | 69 | The only module that writes files: resolves the export root, names artifacts, composes document + annotation, validates rich HTML before write. | writeHtmlArtifact · writeRichHtmlArtifact · getExportRoot |
| src/extension/index.js | 685 | Pi/OMP extension runtime: session state, event wiring (message_end, session restore), slash commands, mode choice UI, Gemini shell-out, rich-export follow-up loop. | module.exports = createExtension(pi) |
| src/extension/messages.js | 97 | Host-event normalization: extract assistant text from arbitrary event shapes, long-answer detection, fenced-HTML extraction. | extractMessageInfo · isLongAnswer · extractHtmlDocument |
| src/extension/parse.js | 60 | Slash-command and argument parsing; maps user args to forced export modes. | parseHtmlCommandInput · resolveForcedExportMode |
| src/extension/open.js | 82 | Opens the written artifact in the default browser via a PATH-resolved platform opener, detached, with a short failure window. | openArtifact · resolveOpenCommand |
| src/extension/prompts.js | 32 | The single prompt template asking a model to redesign a captured answer as a standalone HTML artifact. | buildRichHtmlPrompt |
| src/extension/types.js | 87 | JSDoc typedefs for the host surface and records — no runtime code. | PiHost · ExtensionCtx · SourceRecord · ExportMeta |
| bin/htmlify-answer.js | 228 | CLI: pipe text in to export an artifact, or --validate files against rich/app/deck/auto profiles with text or JSON reports. | main (via _internals) |
| hooks/claude-code-stop-htmlify.js | 70 | Claude Code Stop hook: reads hook JSON from stdin, exports last_assistant_message when it clears HTMLIFY_MIN_CHARS (default 2,500); never fails the agent turn. | main (via _internals) |
| TOTAL | 2,638 | wc -l, 2026-06-11 · orange rows sit on the hot path | |
Three common changes, in order
Add an artifact mode
- Modes are strings, not enums — define the new mode and its sections in
skills/htmlify/SKILL.mdfirst; the runtime carries it through untouched. - If users should force it from a slash command, add an alias in
resolveForcedExportMode(src/extension/parse.js) and a branch inhandleChoice/exportLatestFromCommand(src/extension/index.js). - The mode lands in the filename suffix and plate cell via
writeHtmlArtifact(src/artifacts.js); adjust the mode copy switch inbuildLocalHtmlDocument(src/document.js:341) if the local shell should describe it differently.
Add a validator check
- Put the regex or limit in
src/constants.js— collectors never define their own patterns. - Add the check in
collectRichHtmlIssuesorcollectDeckIssues(src/validate.js), choosing error (blocks the write, fails CI) vs warning (reported only). Mind theallowInlineScriptfork for app/deck. - No further wiring:
validateRichHtmlDocument, the CLI--validatepath, and the runtime gate all consume the collectors. Only a brand-new collector needs anindex.jsfacade entry and a CLI profile branch.
Add an agent integration
- Copy the shape of
hooks/claude-code-stop-htmlify.js: a small executable that reads the agent's payload from stdin and exits 0 even on failure. - Call the facade —
require('../index.js')._internals— forrenderMarkdownish+writeHtmlArtifact; expose thresholds and paths as env vars (HTMLIFY_MIN_CHARS,HTMLIFY_EXPORT_ROOT). - Register it in the agent's hook config, then document install steps in
skills/htmlify/references/agent-integrations.mdalongside the existing Codex / Claude Code / Cursor entries.