Implementation-Map

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.

Scoperuntime · src/
Modules17 files · 2,638 LOC
Date2026-06-11
Branchv1
1.0 · System ShapeHot Path Marked

Module dependency graph

ENTRY POINTS RUNTIME CORE bin/htmlify-answer.js hooks/claude-code-stop-… Pi / OMP host CLI · EXPORT + --VALIDATE CLAUDE CODE STOP HOOK LOADS createExtension(pi) index.js — facade EXPORTS createExtension · RE-EXPORTS EVERY MODULE VIA _internals src/text.js src/constants.js src/comments.js src/markdown.js src/document.js src/annotation.js src/validate.js src/artifacts.js src/extension/index.js extension/messages.js extension/parse.js extension/open.js extension/prompts.js SHA · ESCAPE · COUNTS LIMITS · SAFETY REGEXES BUNDLE → REVIEW PROMPT renderMarkdownish HARDCOPY DOC SHELL COMMENT LAYER INJECT RICH · APP · DECK writeHtmlArtifact EVENTS · COMMANDS · STATE extractMessageInfo COMMAND + ARG PARSING openArtifact buildRichHtmlPrompt 01 02 04 05 06 07 08 HOT PATH — LONG ANSWER → OPENED ARTIFACT · BADGES MATCH STEPS IN SHEET 2.0
FIG 1 · Module dependency graph, arrows point from dependency to consumer · Source: zakelfassi/htmlify @ v1, require() statements in index.js, src/, bin/, hooks/

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.

2.0 · Hot Pathmessage_end → opened file

Long answer to opened artifact, in eight calls

01
extractMessageInfo(event)
src/extension/messages.js:35
On 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.
02
buildSourceRecord(text)
src/extension/index.js:190
Derives a title, computes character/line/paragraph/word stats, and stores the record as 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.
03
/htmlify → exportLatestFromCommand
src/extension/index.js:504 · parse.js:18
parseHtmlCommandInput matches /htmlify, /html-last, /htmlify-last; resolveForcedExportMode maps args to choose / rich-gemini / rich-pi / local. Default path is the local render.
04
renderMarkdownish(source.text)
src/markdown.js:64
Single-pass line scanner: fenced code becomes a carbon well with a language meta strip, headings shift one level down, blockquotes become callouts, pipe tables become real tables, lists and inline links/bold/code are formatted — all through escapeHtml first.
05
writeHtmlArtifact({ title, bodyHtml, sourceText, mode })
src/artifacts.js:25
Resolves the export root (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.
06
buildLocalHtmlDocument(title, body, meta)
src/document.js:69
Wraps the body in the Hardcopy skin: plate title block with mode/word/character cells, crop marks, numbered index rail from the headings, carbon code wells, dark-mode tokens, and print rules.
07
addCommentableAttributes + injectAnnotationLayer
src/annotation.js:7,160
Tags every block element with 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.
08
writeFile → openArtifact(filePath)
src/artifacts.js:43 · src/extension/open.js:41
File hits disk; 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.

3.0 · Validation PathGate Before Disk

Two doors, one set of collectors

Runtime gate — model HTML

  • extractHtmlDocument (messages.js) pulls a fenced ```html block or bare document out of the model's reply.
  • writeRichHtmlArtifact (artifacts.js) calls validateRichHtmlDocument (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-fallback local 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.js validates files against a profile; detectProfile (validate.js:247) sniffs each document when set to auto: slides + speaker notes → deck, any inline script → app, otherwise rich.
  • app profile (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.
  • deck profile adds the deckify contract via collectDeckIssues: 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 json emits machine-readable reports.
shell · validate this very file
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)
4.0 · File TourLOC Measured 2026-06-11

Seventeen files, one job each

PathLOCResponsibilityKey exports
index.js45Public 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.js72All 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.js79Dependency-free primitives: SHA-1 ids, HTML escaping, slugs, paragraph/line/word counts.sha · escapeHtml · slugify · wordCount
src/markdown.js164Markdownish-to-HTML renderer for the local export path: fences, headings, callouts, pipe tables, lists, inline formatting.renderMarkdownish · formatInline
src/document.js356The Hardcopy document shell: plate, crop marks, index rail, carbon wells, dark mode, print CSS; plus title/excerpt/outline derivation.buildLocalHtmlDocument · deriveTitle · buildOutlineHtml
src/validate.js265Issue 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.js172Review layer: marks blocks commentable and injects the localStorage comment panel (the one place trusted inline script is generated).addCommentableAttributes · injectAnnotationLayer
src/comments.js75Round trip for reviewer comments: validates a downloaded JSON bundle against the captured sourceId and renders it as an agent prompt.validateCommentBundle · buildCommentsPrompt
src/artifacts.js69The 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.js685Pi/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.js97Host-event normalization: extract assistant text from arbitrary event shapes, long-answer detection, fenced-HTML extraction.extractMessageInfo · isLongAnswer · extractHtmlDocument
src/extension/parse.js60Slash-command and argument parsing; maps user args to forced export modes.parseHtmlCommandInput · resolveForcedExportMode
src/extension/open.js82Opens the written artifact in the default browser via a PATH-resolved platform opener, detached, with a short failure window.openArtifact · resolveOpenCommand
src/extension/prompts.js32The single prompt template asking a model to redesign a captured answer as a standalone HTML artifact.buildRichHtmlPrompt
src/extension/types.js87JSDoc typedefs for the host surface and records — no runtime code.PiHost · ExtensionCtx · SourceRecord · ExportMeta
bin/htmlify-answer.js228CLI: 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.js70Claude 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)
TOTAL2,638wc -l, 2026-06-11 · orange rows sit on the hot path
5.0 · Edit SequencesWhere To Cut

Three common changes, in order

SEQ A

Add an artifact mode

  1. Modes are strings, not enums — define the new mode and its sections in skills/htmlify/SKILL.md first; the runtime carries it through untouched.
  2. If users should force it from a slash command, add an alias in resolveForcedExportMode (src/extension/parse.js) and a branch in handleChoice / exportLatestFromCommand (src/extension/index.js).
  3. The mode lands in the filename suffix and plate cell via writeHtmlArtifact (src/artifacts.js); adjust the mode copy switch in buildLocalHtmlDocument (src/document.js:341) if the local shell should describe it differently.
SEQ B

Add a validator check

  1. Put the regex or limit in src/constants.js — collectors never define their own patterns.
  2. Add the check in collectRichHtmlIssues or collectDeckIssues (src/validate.js), choosing error (blocks the write, fails CI) vs warning (reported only). Mind the allowInlineScript fork for app/deck.
  3. No further wiring: validateRichHtmlDocument, the CLI --validate path, and the runtime gate all consume the collectors. Only a brand-new collector needs an index.js facade entry and a CLI profile branch.
SEQ C

Add an agent integration

  1. 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.
  2. Call the facade — require('../index.js')._internals — for renderMarkdownish + writeHtmlArtifact; expose thresholds and paths as env vars (HTMLIFY_MIN_CHARS, HTMLIFY_EXPORT_ROOT).
  3. Register it in the agent's hook config, then document install steps in skills/htmlify/references/agent-integrations.md alongside the existing Codex / Claude Code / Cursor entries.