feat(markdown): render images in DocsUI::Markdown

Merged
MH
14d ago
+178-4

Summary

![alt](src "title") in an md island rendered nothing: DocsUI::Markdown had no node_image handler, so the visitor fell back to visit_children and left only the alt text as stray paragraph text. It now renders a lazy-loaded <img>.

Sourcesrc
relative (dir/shot.png)image_path → /assets/dir/shot-<digest>.png when a view context is present; verbatim in an isolated render
https://…, //…, /…verbatim
javascript:, data:, any other non-http(s) scheme (also JaVaScRiPt: and java<TAB>script:)no <img>
  • alt is the description's text and inline-code content, with markup dropped (![*Emphasised* alt](a.png) → alt="Emphasised alt"). Phlex escapes it.
  • An image title becomes title=. Classes: my-4 rounded-box border border-base-300 (IMAGE_CLASSES), all already in the kit's Tailwind vocabulary.
  • The .md twin already exported <img> as ![alt](src) (MarkdownExport::Inline#image). A new spec covers the full round trip.
  • The handler lives in a new DocsUI::MarkdownImages mixin (app/components/docs_ui/markdown_images.rb), included by DocsUI::Markdown.
  • /docs/markdown gains an Images example that renders the site's own og/og.png.

No new API or config. Backwards compatible: Markdown without images renders exactly as before.

Closes #94

Test plan

  • spec/docs_ui/markdown_spec.rb: relative src through image_path (stubbed view-context seam, as in meta_tags_spec); verbatim with no view context; absolute, protocol-relative and root-relative kept as written; alt built from nested emphasis and code; title; alt escaping; unsafe javascript:, mixed-case, data:, vbscript: and control-character schemes render no <img>
  • spec/docs_kit/markdown_export_spec.rb: DocsUI::Markdown image → .md twin ![Alt text](…)
  • bundle exec rspec: 1066 examples, 0 failures, line coverage 93.0%
  • bundle exec rubocop on the changed files: clean
  • docs/ bin/rspec: 15 examples, 0 failures
  • Booted dogfood app: /docs/markdown renders <img src="/assets/og/og-9a6baa07.png" …>, the asset returns 200, and /docs/markdown.md contains ![The docs-kit landing page](…/assets/og/og-9a6baa07.png)

Before / after (/docs/markdown, Images example)

Before, only the alt text "The docs-kit landing page" appears as a paragraph. After, the image renders:

Before: alt text only
After: the image renders

Deviations & judgment calls

  • Discovery: node_link has no scheme guard — [x](javascript:alert(1)) renders a live javascript: href today (commonmarker keeps the raw URL in the AST; only its HTML renderer sanitises). Left out of scope; worth a follow-up issue.
  • Judgment call: the scheme check strips ASCII whitespace/control characters first (java\tscript: is a scheme to a browser) and drops every non-http(s) scheme (vbscript:, file:, blob:), not only javascript:/data:. An unsafe image renders nothing, alt included — no stray alt prose.
  • Judgment call: IMAGE_CLASSES = "my-4 rounded-box border border-base-300" — the Code block's box look plus Prose's vertical rhythm. No max-w-full added: Tailwind preflight already sets img { max-width: 100% }.
  • Judgment call: alt includes inline-code text (![`x`](a.png) → alt="x"), not just text nodes, so a code word isn't silently lost.
  • Deviation: the issue's spec list didn't ask for a dogfood example; added an ## Images example to /docs/markdown using the site's existing og/og.png, so the feature is visible and screenshot-able.
  • Deviation: the issue put the helpers in markdown.rb; they live in a new DocsUI::MarkdownImages mixin (app/components/docs_ui/markdown_images.rb) because DocsUI::Markdown already sat at RuboCop's 100-line Metrics/ClassLength limit. One unrelated line was also shortened (node_paragraph became an endless def) to get the class back to 100. I chose that over adding a ClassLength exclude.
  • Discovery: whole-repo bundle exec rubocop inspects 0 files when run from a .claude/worktrees/* checkout (the .rubocop.yml excludes .claude/**), so agents in worktrees must lint the changed files by name. I verified it that way.
  • Verified in a booted host: in the dogfood app, og/og.png renders as /assets/og/og-9a6baa07.png, the asset returns 200, and /docs/markdown.md contains ![The docs-kit landing page](http://localhost:3094/assets/og/og-9a6baa07.png).

Summary by cubic

Fixes Markdown images rendering nothing in md islands: ![alt](src "title") now renders a lazy-loaded <img> instead of leaving only the alt text as stray paragraph prose.

  • Relative src paths resolve through the asset pipeline (image_path) with a view context, and are used verbatim in isolated renders; absolute, protocol-relative, and root-relative URLs are kept as written.
  • javascript:, data:, and other non-http(s) schemes — including ones hidden behind whitespace or control characters — render no image.
  • The alt is the description's text and inline code with markup dropped, and a word boundary turns line breaks into spaces; a title becomes the title attribute.
  • The handler lives in a new DocsUI::MarkdownImages mixin so DocsUI::Markdown stays under the class-length limit; the .md export round-trips the image back to ![alt](src).
  • /docs/markdown gains an Images example using the site's og/og.png.

Closes #94.