Skip to content

feat(page): every page renders as Markdown — .md format + copy-page button#33

Merged
mhenrixon merged 1 commit into
mainfrom
issue-17-markdown-twin
Jul 3, 2026
Merged

feat(page): every page renders as Markdown — .md format + copy-page button#33
mhenrixon merged 1 commit into
mainfrom
issue-17-markdown-twin

Conversation

@mhenrixon

Copy link
Copy Markdown
Owner

Closes #17. Part of #8 (Phase 3) — the keystone of the AI story: llms.txt, search, and MCP all consume this.

What & why

Every doc page is now also served as Markdown: GET /docs/x.md returns a faithful GFM twin of exactly what /docs/x renders, and a "Markdown" masthead action links to it. Authors write nothing extra — the twin is derived from the page's own render, so it can never drift from the page the way a hand-written to_text copy does (the app-4 status quo this kills).

How

We emit the HTML ourselves, so the tag vocabulary is bounded and a small hand-rolled Nokogiri visitor converts it exactly. Conversion happens post-render, so Phlex components, md islands, and raw tags in Prose all convert identically — no per-component parallel implementation to drift.

  • DocsKit::MarkdownExport (+ Blocks/Inline/Table visitors): render the page → extract #docs-content → strip [data-md-skip] → walk to GFM. Handles h1–h4, p, a (relative hrefs absolutized via request base URL), strong/em/code, fenced code with language, nested ul/ol/li, GFM tables, > **Tip:** callout blockquotes, hr, img. Unknown elements recurse (text survives, wrapper dropped); script/style excluded.
  • Render-time hints drive the tricky cases:
    • DocsUI::Code stamps data-md-lang (the resolved Rouge tag) → a ```lang fence
    • DocsUI::Callout stamps data-md-callout (the level) → > **Tip:**
    • DocsUI::Shell stamps id="docs-content" (the extraction anchor)
    • DocsUI::Page's "← Home" nav gets data-md-skip
  • Wiring: DocsKit::Controller#render_page branches on request.format.md?/.text?render plain: …to_md, content_type: "text/markdown". The engine registers the text/markdown MIME (:md). The install generator route allows the (.:format) segment.
  • Copy affordance: DocsUI::MarkdownAction — an <a href="….md"> (JS-off: opens raw markdown) that docs-nav enhances into copy-to-clipboard (a new copyMarkdown action + markdownLink target on the one controller — the single-controller rule holds). Config c.page_markdown_action (default true) opts it out.
  • Dependency: nokogiri added to the gemspec (already present in every Rails host; no Gemfile.lock change).

What a consuming site gets

Delta Where
GET /docs/x.md → the page as GFM render_page + the (.:format) route
A "Markdown" masthead action (copy-to-clipboard) DocsUI::Page + DocsUI::MarkdownAction
c.page_markdown_action knob (default true) DocsKit::Configuration

Existing sites re-run docs_kit:install (or add (.:format) to their get "docs/:doc" route) to enable the .md URLs. Sites that don't re-run simply have no .md route match — HTML rendering is untouched (backwards compatible).

Test plan

  • spec/docs_kit/markdown_export_spec.rb — semantic asserts over the full vocabulary: heading levels, fenced language, absolutized links, nested-list indentation, GFM table row, > **Tip:**, chrome / [data-md-skip] / script excluded, plus a full-page acceptance fixture.
  • spec/docs_kit/controller_spec.rb.md/.text render the text/markdown twin; an HTML request still renders layout: false.
  • spec/docs_ui/code_spec.rb / callout_spec.rb — the data-md-lang / data-md-callout hints.
  • spec/docs_ui/markdown_action_spec.rb — the .md href (query-safe, idempotent) + the docs-nav enhancement wiring.
  • spec/docs_kit/configuration_spec.rbpage_markdown_action default + override.
  • spec/generators/install_generator_spec.rb — route allows (.:format), no format: "html" pin.

Verification

  • bundle exec rspec — 297 examples, 0 failures (95.23% line coverage)
  • bundle exec rubocop — no offenses
  • Dogfood: a realistic page (header, section, md island, code, callout, prop table, nested + ordered lists, relative link) → clean GFM
  • docs-nav JS: node --check passes; copyMarkdown degrades to the plain link when clipboard/fetch is unavailable
  • Backwards compatible: no .md route match → HTML rendering untouched

…utton

## Summary

Derives a faithful GFM Markdown twin of every doc page FROM the page's own
render — authors write nothing extra, and the twin can never drift from the
rendered page the way a hand-written `to_text` copy does. This is the
machine-readable layer llms.txt / search / MCP build on.

- `GET /docs/x.md` returns GFM of exactly what `/docs/x` shows.
- A "Markdown" masthead action links to the `.md` (JS-off: opens raw markdown;
  JS-on: docs-nav enhances the click into copy-to-clipboard).

## How

We emit the HTML ourselves, so the tag vocabulary is bounded and a small
hand-rolled Nokogiri visitor converts it exactly (Phlex, `md` islands, and raw
tags in Prose all convert identically — conversion happens post-render).

- `DocsKit::MarkdownExport` (+ Blocks/Inline/Table visitors): render the page,
  extract `#docs-content`, strip `[data-md-skip]`, walk to GFM (h1–h4, p, a with
  relative-href absolutization, strong/em/code, fenced code w/ language,
  nested lists, GFM tables, `> **Tip:**` callouts, hr, img).
- Render-time hints drive the tricky cases: Code stamps `data-md-lang` (resolved
  Rouge tag → fence language), Callout stamps `data-md-callout` (level → label),
  Shell stamps `#docs-content` (extraction anchor), Page's "← Home" nav gets
  `data-md-skip`.
- `DocsKit::Controller#render_page` branches on `request.format.md?`/`.text?`;
  the engine registers the `text/markdown` MIME; the install generator route
  allows the `(.:format)` segment.
- `DocsUI::MarkdownAction` + a `copyMarkdown` action/`markdownLink` target on the
  ONE docs-nav controller (single-controller rule holds). Config
  `c.page_markdown_action` (default true) opts the affordance out.
- nokogiri added to the gemspec (already present in Rails hosts).

## Test Coverage

- markdown_export_spec: semantic asserts over the full vocabulary — heading
  levels, fenced language, absolutized links, nested-list indent, GFM table,
  `> **Tip:**`, chrome/[data-md-skip]/script excluded, full-page acceptance.
- controller_spec: .md/.text → text/markdown twin; html → layout: false.
- code_spec/callout_spec: data-md-lang / data-md-callout hints.
- markdown_action_spec: .md href (query-safe, idempotent) + docs-nav wiring.
- configuration_spec: page_markdown_action default + override.
- install_generator_spec: route allows (.:format), no format: html pin.

## Verification

- [x] bundle exec rspec — 297 examples, 0 failures (95.23% coverage)
- [x] bundle exec rubocop — no offenses
- [x] Dogfood: realistic page → clean GFM (fences/tables/nested lists/links)
- [x] docs-nav JS: node --check passes; copyMarkdown degrades to plain link
- [x] Backwards compatible: no .md route match → HTML rendering untouched

Closes #17
@mhenrixon mhenrixon self-assigned this Jul 3, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 3, 2026
@mhenrixon mhenrixon merged commit b2a4751 into main Jul 3, 2026
3 checks passed
@mhenrixon mhenrixon deleted the issue-17-markdown-twin branch July 4, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(markdown-twin): every page renders as Markdown — .md format + copy-page button

1 participant