feat(page): every page renders as Markdown — .md format + copy-page button#33
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdreturns a faithful GFM twin of exactly what/docs/xrenders, 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-writtento_textcopy 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,
mdislands, and raw tags in Prose all convert identically — no per-component parallel implementation to drift.DocsKit::MarkdownExport(+Blocks/Inline/Tablevisitors): 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.DocsUI::Codestampsdata-md-lang(the resolved Rouge tag) → a```langfenceDocsUI::Calloutstampsdata-md-callout(the level) →> **Tip:**DocsUI::Shellstampsid="docs-content"(the extraction anchor)DocsUI::Page's "← Home" nav getsdata-md-skipDocsKit::Controller#render_pagebranches onrequest.format.md?/.text?→render plain: …to_md, content_type: "text/markdown". The engine registers thetext/markdownMIME (:md). The install generator route allows the(.:format)segment.DocsUI::MarkdownAction— an<a href="….md">(JS-off: opens raw markdown) thatdocs-navenhances into copy-to-clipboard (a newcopyMarkdownaction +markdownLinktarget on the one controller — the single-controller rule holds). Configc.page_markdown_action(defaulttrue) opts it out.nokogiriadded to the gemspec (already present in every Rails host; noGemfile.lockchange).What a consuming site gets
GET /docs/x.md→ the page as GFMrender_page+ the(.:format)routeDocsUI::Page+DocsUI::MarkdownActionc.page_markdown_actionknob (defaulttrue)DocsKit::ConfigurationExisting sites re-run
docs_kit:install(or add(.:format)to theirget "docs/:doc"route) to enable the.mdURLs. Sites that don't re-run simply have no.mdroute 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/.textrender thetext/markdowntwin; an HTML request still renderslayout: false.spec/docs_ui/code_spec.rb/callout_spec.rb— thedata-md-lang/data-md-callouthints.spec/docs_ui/markdown_action_spec.rb— the.mdhref (query-safe, idempotent) + the docs-nav enhancement wiring.spec/docs_kit/configuration_spec.rb—page_markdown_actiondefault + override.spec/generators/install_generator_spec.rb— route allows(.:format), noformat: "html"pin.Verification
bundle exec rspec— 297 examples, 0 failures (95.23% line coverage)bundle exec rubocop— no offensesmdisland, code, callout, prop table, nested + ordered lists, relative link) → clean GFMdocs-navJS:node --checkpasses;copyMarkdowndegrades to the plain link when clipboard/fetch is unavailable.mdroute match → HTML rendering untouched