Skip to content

@docsxai/viewer

Static-HTML interactive viewer + burned-annotation renderer + Starlight docs-site emitter. The viewer overlays a pulsing halo, a numbered badge (when a step has multiple call-outs), and a hover-revealed Popper-placed callout from annotations.json over clean screenshots at render time. Per-annotation nudge: { x, y } lets the author shift a callout aside when two would otherwise overlap; the halo stays anchored on the target.

PNGs in the doc pack stay clean (no baked annotations) - re-stylable, re-localisable, and machine-inspectable. For delivery surfaces that can’t run the interactive viewer (Confluence, Notion, plain wikis), burn bakes the same annotations into copies of the PNGs.

  • buildViewer({ docsDir, outDir }) - reads <docsDir>/<flow>/annotations.json + screenshots, emits <outDir>/index.html + per-flow pages. Idempotent.
  • placeCallout(input) in src/placement.ts - Popper-like placement logic. Pure, coordinate-space-agnostic; tested independently. The single placement implementation shared by the browser overlay and the burner.
  • burnAnnotations({ screenshotPath | screenshotBuffer, annotations, options? }) - returns the burned PNG as a Buffer.
  • burnFlow({ docsDir, flow, outDir? }) - batch helper: burns every screenshot of a flow into docs/<flow>/burned/ (annotation-less steps are copied unchanged so the directory is the complete drop-in image set).
  • emitStarlightSite({ workspaceDir, outDir, config? }) / buildStarlightSite({ siteDir }) - the production docs-site renderer; see Starlight site.
  • docsxai-viewer bin:
    • docsxai-viewer build <docs-dir> <out-dir> [--flow <name>]... - the engine’s docsxai render shells out to this.
    • docsxai-viewer burn <workspace> [--flow <name>]... [--out <dir>] - writes docs/<flow>/burned/<step>.png.
    • docsxai-viewer site <workspace> [--out <dir>] [--build] [--title <t>] [--accent <hex>] [--flow <name>]... - emits (and with --build builds) the Starlight site.

emitStarlightSite writes a complete, buildable Astro Starlight project from a doc pack - the production docs-site renderer beside the single-file interactive viewer, not a replacement for it. The first-party plugin package @docsxai/plugin-starlight exposes it to the engine’s plugin runtime as the starlight:site renderer.

What gets emitted:

  • One MDX page per flow - an H2 per step, the step’s <step>.md prose verbatim, and an <AnnotatedShot> figure per screenshot. The figure’s caption lists each annotation’s copy, numbered (<li value>) to match the badge indexes burned into the image - caption numbering and burned pixels can’t drift apart.
  • Burned-image preference - docs/<flow>/burned/<step>.png is copied when present, the clean screenshot is the fallback, and a missing image becomes a placeholder plus a warning (never a failure).
  • A landing page of flow link-cards with step/annotation counts, and a sidebar ordered by the workspace’s flow extends graph (roots alphabetical, children nested DFS; flows without a flow-file append alphabetically) - the same shape as docsxai flow-tree.
  • Theme from the style artifact - docs/style.json’s visual keys (brand_color > accent > primary_color) become a derived --sl-color-accent-* scale for both color schemes; visual.logo is copied in. Explicit --title / --accent / --logo config overrides win. An unparsable style accent is a warning; an unparsable explicit accent is an error.
  • Pinned, self-contained output - the emitted package.json exact-pins astro@6.4.6 + @astrojs/starlight@0.40.0 (both MIT; matching this package’s devDependencies, where the pair is tested). No remote fonts, no CDN imports anywhere in the emitted tree - Starlight ships its own assets and Pagefind search at build time; a test greps every emitted text file for external URLs.
  • Deterministic - same doc pack + same config → byte-identical file tree (no timestamps, sorted writes), asserted by a two-emit golden test.

buildStarlightSite({ siteDir }) runs astro build programmatically: it resolves the astro bin from this package’s own install and, when the emitted site has no node_modules, symlinks the astro + starlight installs in individually - so building never touches the network. ASTRO_TELEMETRY_DISABLED=1 is always set. That zero-install shortcut requires the site directory to share a filesystem ancestor with the docsxai install (the normal case - the site is emitted inside the repo that installed it); for a fully detached site directory, npm install inside the emitted site and build there. The real-build E2E test is opt-in via DOCSX_STARLIGHT_BUILD=1 (the default test run never invokes astro).

The script inlined into every flow page is generated, not hand-maintained. src/overlay-runtime.ts (browser-side DOM logic) imports the real placeCallout from src/placement.ts; the package build runs scripts/bundle-overlay.mjs (esbuild API) before tsc, bundling it to dist/generated/overlay.js - an unminified es2019 IIFE with no sourcemap, kept readable for auditability. render.ts reads that bundle at render time (resolved relative to import.meta.url, with a src/../dist/ fallback for running from source) and inlines it into each page. The bundle is byte-deterministic for a given esbuild version and is not committed; pnpm build (or pnpm test, which bundles first) produces it.

Every emitted page carries Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'

  • matching the inline-asset reality (inline <style>/<script>, workspace-local images) while blocking all network egress: no CDN fetches, no remote fonts, no beacons. The emitted HTML is fully self-contained.

docs/<flow>/<step>.md files render through micromark in its safe default mode: raw HTML in the markdown is escaped and dangerous link protocols are dropped, so a write-up can’t introduce markup or script into the page.

Design constraints, in order:

  • Browser-free. No Chromium, no Playwright, no DOM. The pipeline is Satori (HTML/CSS-subset flexbox layout → SVG) → @resvg/resvg-js (SVG → PNG). A regression test asserts no viewer source module imports playwright.
  • Deterministic. Same inputs → byte-identical PNG, asserted by a two-run golden test. The clean screenshot is embedded in the Satori tree as a data-URI <img>, so the whole frame rasterises in a single resvg pass - one encoder produces every output byte and there is no separate composite/re-encode step. Satori layout and resvg rasterisation are pure functions of their inputs; system fonts are never loaded; text is emitted as glyph paths; resvg writes no timestamps.
  • Faithful to the interactive viewer. Halo (accent border + glow) on the bounding box, numbered badge when index is present, rounded-rect callout (white background, 1px border) with copy wrapped to the same 280px outer clamp, triangle arrow per arrow_style (8 directions), nudge offsets applied to callout + arrow only. Placement reuses placeCallout; text measurement/wrapping uses the vendored font’s own cmap/hmtx metrics (src/font-metrics.ts) as the burner’s stand-in for the overlay’s DOM measuring probe.
  • Engine-decoupled. The annotation record type is redeclared structurally in src/annotations.ts - it mirrors the docsxai/annotations@1 schema; the viewer never imports the engine package.

assets/fonts/inter-regular.ttf - Inter Regular v4.1 from the official rsms/inter release, licensed under the SIL Open Font License 1.1 (assets/fonts/LICENSE.txt). Satori needs raw font bytes; only the Regular weight ships, so bold-ish elements (the badge) render in Regular.

Apache-2.0. Runtime deps: satori (MPL-2.0), @resvg/resvg-js (MPL-2.0), micromark (MIT); vendored Inter font (OFL-1.1).

Made by Kalebtec · GitHub · Apache-2.0 licensed