Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ A `DocsUI::` Phlex kit, configured once per site:
| `DocsUI::RequestExample` | One request declaration → one code tab per configured client (curl / JS / Ruby / Python by default). |
| `DocsUI::JsonResponse` | A Ruby Hash (or String) rendered as a pretty-printed JSON response block. |
| `DocsUI::Example` | Base for a live example with `method_source`-extracted source. |
| `DocsUI::MarkdownAction` | The "Markdown" masthead action → the page's `.md` twin; `docs-nav` enhances it into copy-to-clipboard. |

Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem`
(sidebar link value object), and `DocsKit::Controller#render_page`.
(sidebar link value object), `DocsKit::MarkdownExport` ([every page as
Markdown](#every-page-is-also-markdown)), and `DocsKit::Controller#render_page`.

## Install

Expand Down Expand Up @@ -263,6 +265,40 @@ this page" TOC still come from `DocsUI::Section`** — keep section titles as
`Section`, and use Markdown headings only for sub-headings inside a section. Raw
HTML in the Markdown source is dropped (no `<script>`, no passthrough).

## Every page is also Markdown

Every doc page is **also** served as Markdown — append `.md` to its URL:

```bash
curl https://your-docs.example/docs/installation.md
```

returns a faithful GFM twin of exactly what `/docs/installation` renders —
headings, fenced code (with the right language), callouts as `> **Tip:**`
blockquotes, GFM tables, links (relative links absolutized to full URLs). You
write **nothing extra**: the twin is derived from the page's own render
(`DocsKit::MarkdownExport` walks the rendered HTML), so it can never drift from
the page the way a hand-written `to_text` copy does.

Each page's masthead carries a small **"Markdown"** action. With JavaScript off
it's a plain link that opens the raw `.md`; with JS on, `docs-nav` upgrades the
click into **copy-the-page-to-clipboard** — one click to paste a whole doc page
into an LLM. This is the machine-readable layer `llms.txt`, search, and MCP build
on.

Nothing to wire up — the install generator's route allows the `.:format`
segment, the engine registers the `text/markdown` MIME, and
`DocsKit::Controller#render_page` returns the twin for a `.md`/`.text` request.
To hide the masthead action site-wide (the `.md` route still works):

```ruby
DocsKit.configure { |c| c.page_markdown_action = false }
```

**Existing sites:** re-run `bin/rails g docs_kit:install` (or add `(.:format)`
to your `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.

## API docs — one request, every client tab

An endpoint example is a request shown in several clients (curl, JavaScript,
Expand Down
11 changes: 9 additions & 2 deletions app/components/docs_ui/callout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ class Callout < Phlex::HTML
}.freeze

def initialize(level = :note, title: nil)
@config = LEVELS.fetch(level, LEVELS[:note])
# Normalize an unknown level to :note so both the styling and the
# data-md-callout export hint agree (never a bogus level name leaking out).
@level = LEVELS.key?(level) ? level : :note
@config = LEVELS[@level]
@title = title
end

def view_template(&)
div(class: "not-prose alert #{@config[:klass]} my-4 items-start", role: "note") do
# data-md-callout carries the level (note/tip/warning) so
# DocsKit::MarkdownExport renders `> **Tip:** …` without reverse-engineering
# the level from the alert-* class.
div(class: "not-prose alert #{@config[:klass]} my-4 items-start", role: "note",
data: { md_callout: @level }) do
render DocsUI::Icon.new(@config[:icon], class: "size-5 shrink-0")
div do
div(class: "font-semibold") { @title } if @title
Expand Down
8 changes: 6 additions & 2 deletions app/components/docs_ui/code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ def view_template
# request there is no nonce (see #csp_nonce) and Phlex omits a nil-valued
# attribute, so the no-nonce markup is unchanged.
style(nonce: csp_nonce) { highlight_css }
resolved = lexer
div(class: "not-prose my-4 overflow-hidden rounded-box border border-base-300 bg-base-300/40") do
title_bar if @filename
div(class: "code-highlight overflow-x-auto p-4 text-sm leading-relaxed") do
pre { raw(safe(FORMATTER.format(lexer.lex(@source)))) }
# data-md-lang carries the RESOLVED Rouge tag (ruby/python/plaintext/…) so
# DocsKit::MarkdownExport emits a ```lang fence without re-resolving the
# language. It's the real lexer tag, not the requested alias.
div(class: "code-highlight overflow-x-auto p-4 text-sm leading-relaxed", data: { md_lang: resolved.tag }) do
pre { raw(safe(FORMATTER.format(resolved.lex(@source)))) }
end
end
end
Expand Down
45 changes: 45 additions & 0 deletions app/components/docs_ui/markdown_action.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module DocsUI
# The "Markdown" masthead affordance: a link to the current page's `.md` twin.
# With JS off it simply opens the raw Markdown (a working, no-JS fallback); the
# docs-nav controller enhances the click into copy-to-clipboard (a new target +
# action on the ONE controller — the single-controller rule holds).
#
# render DocsUI::MarkdownAction.new(request.path)
#
# Rendered by DocsUI::Page when DocsKit.configuration.page_markdown_action is
# true (the default). The `.md` twin itself is produced by
# DocsKit::Controller#render_page → DocsKit::MarkdownExport.
class MarkdownAction < Phlex::HTML
LABEL = "Markdown"
CLASSES = "btn btn-ghost btn-xs gap-1 opacity-70 hover:opacity-100"

def initialize(path)
@path = path.to_s
end

def view_template
a(
href: md_href,
class: CLASSES,
# JS-ON: docs-nav intercepts the click, fetches the .md, copies it, and
# (because the browser default is prevented) never navigates away.
data: { docs_nav_target: "markdownLink", action: "docs-nav#copyMarkdown" }
) do
render DocsUI::Icon.new("clipboard", class: "size-3.5")
plain LABEL
end
end

private

# The `.md` twin URL: the request path with a `.md` extension, preserving any
# query string. Idempotent — a path already ending in `.md` is left as-is.
def md_href
path, query = @path.split("?", 2)
path = "#{path}.md" unless path.end_with?(".md")
query ? "#{path}?#{query}" : path
end
end
end
13 changes: 12 additions & 1 deletion app/components/docs_ui/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ module DocsUI
# end
class Page < Phlex::HTML
include Phlex::Rails::Helpers::Routes
# #request drives the "Markdown" masthead action's href (request.path + .md).
include Phlex::Rails::Helpers::Request
# Authored pages subclass this, so include the kit here: a page body can call
# DocsUI::Section(...) / DocsUI::Code(...) directly, no render ... .new.
include DocsUI
Expand Down Expand Up @@ -45,8 +47,13 @@ def on_page(value = :__unset__)

def view_template
render DocsUI::Shell.new(title: self.class.title, on_page: self.class.on_page) do
nav(class: "mb-6") do
# data-md-skip drops this nav from the Markdown export — it's chrome, not
# page content (DocsKit::MarkdownExport strips [data-md-skip]). The
# "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
# inside the skipped nav and never appears in the .md twin.
nav(class: "mb-6 flex items-center justify-between gap-4", data: { md_skip: true }) do
a(href: root_path, class: "link link-hover text-sm opacity-70") { "← Home" }
render DocsUI::MarkdownAction.new(request.path) if markdown_action?
end

render DocsUI::Header.new(self.class.title, eyebrow: self.class.eyebrow) do
Expand All @@ -57,6 +64,10 @@ def view_template
end
end

# Whether to show the "Markdown" masthead action — the config knob
# (DocsKit.configuration.page_markdown_action, default true).
def markdown_action? = DocsKit.configuration.page_markdown_action

# The lowercase authoring helpers md/prose/example come from DocsUI::PageHelpers
# (included above) — the parens-free path that never hits the constant-reference
# SyntaxError. The kit forms (DocsUI::Prose(), DocsUI::Example()) stay valid too.
Expand Down
5 changes: 4 additions & 1 deletion app/components/docs_ui/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ def shell(&block)
# it here so it's inside the docs-nav controller scope; the controller
# fills it from the page headings.
render DocsUI::OnThisPage.new(mode: @on_page) if content_toc?
div(class: "mx-auto max-w-4xl", &block)
# id="docs-content" is the stable extraction anchor for the Markdown
# export (DocsKit::MarkdownExport walks this subtree). The topbar,
# sidebar, and TOC live OUTSIDE it, so they never bleed into the .md.
div(id: "docs-content", class: "mx-auto max-w-4xl", &block)
end
end

Expand Down
40 changes: 40 additions & 0 deletions app/javascript/docs_kit/controllers/docs_nav_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ export default class extends Controller {
// tocPopover: the collapsible card revealed by the floating toggle button.
// codeGroup/codeTab/codePanel: a multi-language Docs::Example — the controller
// shows the panel for the globally-remembered language and hides the others.
// markdownLink: the "Markdown" masthead action; a plain link with JS off, the
// controller upgrades its click into copy-the-page's-markdown-to-clipboard.
static targets = [
"tocLink", "toc", "tocRoot", "tocPopover",
"codeGroup", "codeTab", "codePanel",
"markdownLink",
]

connect() {
Expand Down Expand Up @@ -303,6 +306,43 @@ export default class extends Controller {
this.tocPopoverTargets.forEach((el) => el.classList.toggle("hidden"))
}

// --- 6. Copy page as Markdown ----------------------------------------------
//
// The "Markdown" masthead action is an <a href="….md"> — with JS off it opens
// the raw Markdown twin (a working fallback). Here we intercept the click,
// fetch that same .md, and copy it to the clipboard so the reader can paste the
// page into an LLM. No server round-trip beyond fetching the page that already
// exists. Anything unavailable (no clipboard API, fetch fails) falls back to
// the link's default navigation, so the affordance is never a dead end.

async copyMarkdown(event) {
const link = event.currentTarget
const href = link.getAttribute("href")
if (!href || !navigator.clipboard) return // let the browser follow the link

event.preventDefault()
try {
const response = await fetch(href, { headers: { Accept: "text/markdown" } })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const markdown = await response.text()
await navigator.clipboard.writeText(markdown)
this.flashCopied(link)
} catch {
// Fetch/clipboard failed — navigate to the raw .md as the plain link would.
window.location.href = href
}
}

// Briefly swap the link's label to confirm the copy, then restore it. Uses the
// trailing text node so the leading icon (if any) is untouched.
flashCopied(link) {
const labelNode = Array.from(link.childNodes).reverse().find((n) => n.nodeType === 3)
if (!labelNode) return
const original = labelNode.textContent
labelNode.textContent = "Copied!"
setTimeout(() => (labelNode.textContent = original), 1500)
}

// --- storage (private, fails safe if localStorage is unavailable) -----------

read(key) {
Expand Down
4 changes: 4 additions & 0 deletions docs-kit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ Gem::Specification.new do |s|
# strikethrough + autolink on by default). We walk its AST to Phlex nodes, so
# commonmarker never renders HTML we'd have to html_safe.
s.add_dependency "commonmarker", "~> 2.0"
# HTML→Markdown export (DocsKit::MarkdownExport): we render a page, then walk
# the HTML with a Nokogiri visitor to derive its GFM twin. Universally present
# in Rails hosts already (loofah/rails-html-sanitizer depend on it).
s.add_dependency "nokogiri", ">= 1.15"
s.add_dependency "zeitwerk", "~> 2.6"

# phlex-reactive (reactive demos) and pgbus (Postgres-SSE transport) are
Expand Down
7 changes: 7 additions & 0 deletions lib/docs_kit/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ class Configuration
# (e.g. { elixir: "Elixir", curl: "cURL" }). Unknown tokens humanize.
attr_accessor :code_language_labels

# Whether DocsUI::Page shows the "Markdown" masthead action — a link to the
# page's `.md` twin that docs-nav enhances into copy-to-clipboard. Defaults to
# true; set false to hide the affordance site-wide (the `.md` route still
# works). See DocsKit::MarkdownExport / DocsKit::Controller#render_page.
attr_accessor :page_markdown_action

# The API base URL prefixed onto a DocsUI::RequestExample path so copy-pasted
# snippets point at a real host. Defaults to a neutral example host; a site
# sets its own (e.g. "https://api.acme.com").
Expand Down Expand Up @@ -180,6 +186,7 @@ def initialize
@code_lexer_aliases = {}
@code_lexer_fallback = "plaintext"
@code_language_labels = {}
@page_markdown_action = true
@api_base_url = "https://api.example.com"
@api_auth_header = nil
@api_clients = {}
Expand Down
26 changes: 26 additions & 0 deletions lib/docs_kit/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,34 @@ module Controller
# Rails ERB application layout from double-nesting <html>. phlex-rails still
# renders through a real view context, so CSRF, dom_id, url helpers, and the
# phlex-reactive token signer all work inside components.
#
# A `.md`/`.text` request instead returns the page's Markdown twin, derived
# from the SAME render (DocsKit::MarkdownExport walks the rendered HTML). So
# `GET /docs/x.md` is faithful GFM of exactly what `/docs/x` shows — the
# author writes nothing extra, and the two never drift.
def render_page(view)
return render_markdown(view) if markdown_request?

render view, layout: false
end

private

# True for a `.md` or `.text` request. `.text` is accepted as an alias so a
# host whose routes only allow the built-in `:text` format still gets the
# twin.
def markdown_request?
request.format.md? || request.format.text?
end

# The Markdown twin as text/markdown. Rendered through the controller's view
# context (so url helpers/CSRF resolve) and with the request base URL so
# relative links in the export are absolutized to portable URLs.
def render_markdown(view)
markdown = DocsKit::MarkdownExport.new(
view, view_context:, base_url: request.base_url
).to_md
render plain: markdown, content_type: "text/markdown"
end
end
end
8 changes: 8 additions & 0 deletions lib/docs_kit/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ class Engine < ::Rails::Engine
end
end

# Register the :md format (text/markdown) so a `.md` request routes and
# `request.format.md?` is true — the trigger for the page's Markdown twin
# (DocsKit::Controller#render_page). Guarded so re-registration (a host that
# already declared :md) is a no-op rather than a duplicate-type error.
initializer "docs_kit.mime_types" do
Mime::Type.register("text/markdown", :md) unless Mime::Type.lookup_by_extension(:md)
end

# Serve the bundled Stimulus controller (docs_nav) as an asset.
initializer "docs_kit.assets" do |app|
app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
Expand Down
Loading
Loading