From 61a3acd9f3db658059f85507e939f42bf4631022 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 11:43:33 +0200 Subject: [PATCH] feat(llms): serve /llms.txt + /llms-full.txt from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every docs-kit site now serves the two llmstxt.org artifacts, built from the registry with zero authoring, making the docs AI-consumable without HTML scraping. ## Summary - DocsKit::LlmsText (lib/docs_kit/llms_text.rb) — a pure, Rails-free builder: .index → the llms.txt index (H1 brand, `> tagline` blockquote, one `##` section per nav group, tight `- [Title](abs .md url)` bullet list per authored page); .pages → authored pages across every registry; .full → each page's Markdown joined by `---`. Unwritten pages (no view_class) are excluded, so no dead links. - DocsKit::LlmsController (app/controllers/docs_kit/llms_controller.rb) — thin, ships in the gem; routes live in the host app (the engine stays glue-only). Both actions render text/plain; charset=utf-8 and HTTP-cache via stale?/etag on [VERSION, body]. #full threads the Rails view context so MarkdownExport can render each page's twin. - c.tagline config knob (default nil → blockquote omitted). - Install generator scaffolds both routes idempotently; docs-kit.rb.erb documents the tagline knob. - Dogfood: docs/ routes + a tagline; docs route gains (.:format) so its .md links resolve. - README "AI-readable docs (llms.txt)" section. Rails' own autoloader owns DocsKit::LlmsController from app/controllers/docs_kit/ (the engine already opts app/ out of the gem's zeitwerk loader; the controller's ActionController::Base superclass is Rails-only). The gem controller deliberately does NOT define #config — RequestForgeryProtection delegates allow_forgery_protection to it, so shadowing it with DocsKit.configuration broke csrf_meta_tags when #full renders a page's ; the DocsKit config reader is #docs_config. ## Test Coverage - spec/docs_kit/llms_text_spec.rb — H1/blockquote/H2 groups/tight bullet lists, .md suffixes, absolute + relative URLs, tagline omission, multi-registry order, empty-registry, authored-only .pages, .full concatenation. 100%. - spec/docs_kit/llms_controller_spec.rb — source wiring: ships at the Rails autoload path, subclasses AC::Base, text/plain, builds via LlmsText, etag, and the #config-shadowing regression guard. - spec/docs_kit/configuration_spec.rb — c.tagline default + override (Config 100%). - spec/generators/install_generator_spec.rb — both routes added + idempotent re-run (no duplicates). ## Verification - [x] bundle exec rubocop — 78 files, no offenses - [x] bundle exec rspec — 321 examples, 0 failures; Configuration/Registry/LlmsText 100% - [x] Dogfood: curl /llms.txt (index, 9 working .md links) + /llms-full.txt (9 pages, 8 rules) - [x] Generator re-run does not duplicate routes --- README.md | 37 ++++ app/controllers/docs_kit/llms_controller.rb | 76 ++++++++ docs/config/initializers/docs_kit.rb | 3 + docs/config/routes.rb | 8 +- lib/docs_kit/configuration.rb | 7 + lib/docs_kit/llms_text.rb | 85 +++++++++ .../docs_kit/install/install_generator.rb | 7 + .../install/templates/docs_kit.rb.erb | 4 + spec/docs_kit/configuration_spec.rb | 12 ++ spec/docs_kit/llms_controller_spec.rb | 56 ++++++ spec/docs_kit/llms_text_spec.rb | 178 ++++++++++++++++++ spec/generators/install_generator_spec.rb | 16 ++ 12 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 app/controllers/docs_kit/llms_controller.rb create mode 100644 lib/docs_kit/llms_text.rb create mode 100644 spec/docs_kit/llms_controller_spec.rb create mode 100644 spec/docs_kit/llms_text_spec.rb diff --git a/README.md b/README.md index 7ce1a10..65df2d4 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,43 @@ DocsKit.configure { |c| c.page_markdown_action = false } 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. +## AI-readable docs (llms.txt) + +Every site serves the two [llmstxt.org](https://llmstxt.org) artifacts, built +from the **registry** with zero authoring: + +```bash +curl https://your-docs.example/llms.txt # the index +curl https://your-docs.example/llms-full.txt # every page, concatenated +``` + +`/llms.txt` is the index an agent fetches first: an H1 brand, an optional +one-line summary blockquote, one `##` section per nav group, and a +`- [Title](…/page.md)` link to each authored page's Markdown twin. `/llms-full.txt` +concatenates every page's Markdown (the same twin as `.md`) into one document, +separated by `---`. Both are `text/plain`, HTTP-cached (they revalidate on the +registry's content plus the gem version), and derived from the same registry the +sidebar uses — an unwritten page never appears, so there are no dead links. + +Set the summary blockquote with the `tagline` knob (default `nil` → the line is +omitted): + +```ruby +DocsKit.configure { |c| c.tagline = "The one-line description agents see." } +``` + +The controller ships in the gem (`DocsKit::LlmsController`); the **routes live in +your app** so you keep full control over path, auth, and omission. The install +generator scaffolds them: + +```ruby +get "/llms.txt" => "docs_kit/llms#index", as: :llms +get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full +``` + +**Existing sites:** re-run `bin/rails g docs_kit:install` (it adds the two routes +idempotently), or paste the two lines above into `config/routes.rb`. + ## API docs — one request, every client tab An endpoint example is a request shown in several clients (curl, JavaScript, diff --git a/app/controllers/docs_kit/llms_controller.rb b/app/controllers/docs_kit/llms_controller.rb new file mode 100644 index 0000000..1c9cb7e --- /dev/null +++ b/app/controllers/docs_kit/llms_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module DocsKit + # Serves the two AI-readable artifacts (llmstxt.org) from the registry, with + # zero authoring — the host app wires the routes (the engine is glue-only, no + # routes of its own), so a site keeps full control over path, auth, and + # omission: + # + # # config/routes.rb + # get "/llms.txt" => "docs_kit/llms#index" + # get "/llms-full.txt" => "docs_kit/llms#full" + # + # #index → the llms.txt index (brand, tagline, nav-grouped links to each page's + # `.md` twin). #full → llms-full.txt (every page's Markdown concatenated). Both + # are text/plain and HTTP-cached: the response revalidates on the registry's + # own content plus DocsKit::VERSION, so a page/gem change busts the cache while + # an unchanged registry serves a 304. + # + # All the text shaping lives in DocsKit::LlmsText (pure, Rails-free). This + # controller only threads the Rails view context: #full renders each page to + # Markdown via DocsKit::MarkdownExport (which needs url helpers/CSRF), then + # hands the [title, markdown] pairs to LlmsText.full. + class LlmsController < ActionController::Base + # #full renders each page's full HTML through this controller's view context + # (DocsKit::MarkdownExport), and DocsUI::Shell's calls csrf_meta_tags — + # which needs protect_against_forgery? registered as a view helper. A gem's + # bare ActionController::Base subclass doesn't inherit the host app's + # default_protect_from_forgery, so declare it here. :null_session fits these + # GET-only, sessionless, public text endpoints (no token to verify). + protect_from_forgery with: :null_session + + def index + body = DocsKit::LlmsText.index(docs_config, base_url: request.base_url) + render_text(body) if stale_llms?(body) + end + + def full + pairs = DocsKit::LlmsText.pages(docs_config).map do |page| + [page.title, render_page_markdown(page)] + end + body = DocsKit::LlmsText.full(docs_config, pairs) + render_text(body) if stale_llms?(body) + end + + private + + # NOT named #config — ActionController::Base#config is the Rails config + # object, and RequestForgeryProtection delegates allow_forgery_protection/ + # csrf_token_storage_strategy to it (`delegate ..., to: :config`). Shadowing + # #config with DocsKit.configuration would route those to the wrong object and + # blow up csrf_meta_tags when #full renders a page's . + def docs_config = DocsKit.configuration + + # text/plain (llms.txt is plain text, not markdown — agent tooling fetches it + # as-is). UTF-8 because page titles/taglines may carry non-ASCII. + def render_text(body) + render plain: body, content_type: "text/plain; charset=utf-8" + end + + # Revalidate on the rendered body itself (so any registry/config/page change + # busts it) plus the gem version as the etag salt. In development, always + # re-render; production sites deploy immutably so the version etag is stable. + def stale_llms?(body) + stale?(etag: [DocsKit::VERSION, body], public: true) + end + + # A page's Markdown twin, rendered through this controller's view context so + # url helpers/CSRF resolve and relative links absolutize to portable URLs — + # the same path DocsKit::Controller#render_page takes for a `.md` request. + def render_page_markdown(page) + DocsKit::MarkdownExport.new( + page.view_class.new, view_context:, base_url: request.base_url + ).to_md + end + end +end diff --git a/docs/config/initializers/docs_kit.rb b/docs/config/initializers/docs_kit.rb index 82afc55..9786961 100644 --- a/docs/config/initializers/docs_kit.rb +++ b/docs/config/initializers/docs_kit.rb @@ -9,6 +9,9 @@ DocsKit.configure do |c| c.brand = "docs-kit" c.title_suffix = "docs-kit" + # The one-line summary in /llms.txt (the llmstxt.org blockquote). Default nil + # omits the line; set it so AI agents get a crisp description of the site. + c.tagline = "Shared Phlex/daisyUI chrome for documentation sites — one shell, sidebar, code kit, and page kit across every docs site." c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset] # Code blocks carry a light theme by default and swap to a dark theme when the diff --git a/docs/config/routes.rb b/docs/config/routes.rb index e57ac75..11ad09b 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -1,6 +1,12 @@ Rails.application.routes.draw do root "landings#show" - get "docs/:doc" => "docs#show", as: :doc + get "docs/:doc(.:format)" => "docs#show", as: :doc + + # AI-readable docs (llmstxt.org) — served from the registry by the gem's + # DocsKit::LlmsController, zero authoring. /llms.txt is the index; /llms-full.txt + # concatenates every page's Markdown twin. + get "/llms.txt" => "docs_kit/llms#index", as: :llms + get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb index d0d9129..130046e 100644 --- a/lib/docs_kit/configuration.rb +++ b/lib/docs_kit/configuration.rb @@ -16,6 +16,12 @@ class Configuration # The brand text shown in the topbar and sidebar header. attr_accessor :brand + # A one-line site summary, rendered as the llms.txt blockquote + # (`> {tagline}`) under the H1. Defaults to nil → the blockquote line is + # omitted, so a site that never sets it still gets a valid llms.txt. Purely + # for the AI-readable index (DocsKit::LlmsText); the chrome never shows it. + attr_accessor :tagline + # The href the topbar brand link points at. Defaults to "/" (site root). A # site whose docs live under a subpath sets its own (e.g. "/docs") so the # brand link is a one-line config change, not a Shell subclass. @@ -165,6 +171,7 @@ class Configuration def initialize @brand = "Docs" + @tagline = nil @brand_href = "/" @title_suffix = nil @themes = %w[dark light] diff --git a/lib/docs_kit/llms_text.rb b/lib/docs_kit/llms_text.rb new file mode 100644 index 0000000..4ba3281 --- /dev/null +++ b/lib/docs_kit/llms_text.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module DocsKit + # Builds the two AI-readable artifacts a docs-kit site serves, straight from + # the registry — zero authoring: + # + # /llms.txt — the llmstxt.org index: H1 brand, `> tagline` blockquote, + # one `## {group}` section per nav group, and a + # `- [title](abs .md url)` line per authored page. + # /llms-full.txt — every page's Markdown twin concatenated, `# {title}` + + # body, separated by `---`. + # + # It's a pure text builder: given a DocsKit::Configuration and (for the full + # form) already-rendered `[title, markdown]` pairs, it produces strings with no + # Rails. The controller owns the Rails view context — it renders each page to + # Markdown (DocsKit::MarkdownExport) and hands the pairs to .full — so all the + # shaping is unit-testable without booting Rails. + # + # The enumeration source is DocsKit::Registry v2: each registry in + # `config.nav_registries` responds to #nav_items ({ group => [NavItem] }, + # authored pages only) for the index and #all (entries with #view_class) for + # the authored page list. An unwritten page (no resolvable view_class) is + # excluded from both, so neither artifact ever links or concatenates a page + # that doesn't exist yet. + module LlmsText + module_function + + # The llms.txt index string. base_url absolutizes each page's `.md` href so + # agent tooling fetches a portable URL; omit it for relative links. + # + # Blocks (H1, the tagline blockquote, and one per section) are separated by a + # blank line; within a section the `## heading` and its `- [..]` bullets are a + # single tight list (no blank lines between bullets), per the llmstxt.org + # convention. + def index(config, base_url: nil) + blocks = ["# #{config.brand}"] + tagline = config.tagline + blocks << "> #{tagline}" if tagline && !tagline.to_s.empty? + + groups(config).each do |group, links| + section = ["## #{group}", *links.map { |link| link_line(link, base_url) }] + blocks << section.join("\n") + end + + blocks.join("\n\n") + end + + # The authored pages across every registry, in config/registry order — each + # responds to #title / #href / #view_class. The controller renders these to + # Markdown for .full. + def pages(config) + config.nav_registries.values.flat_map { |registry| registry.all.select(&:view_class) } + end + + # The llms-full.txt body: each [title, markdown] pair as `# {title}` + body, + # separated by a `---` rule. Empty pairs → "". + def full(_config, title_markdown_pairs) + title_markdown_pairs.map { |title, markdown| "# #{title}\n\n#{markdown}" }.join("\n\n---\n\n") + end + + # { group => [links] } across every registry's #nav_items, in config order. + # A registry with no authored pages contributes nothing, so no empty section + # is ever emitted. + def groups(config) + config.nav_registries.values.each_with_object({}) do |registry, acc| + registry.nav_items.each { |group, links| (acc[group] ||= []).concat(links) } + end + end + + # `- [label](absolute .md url)`. The `.md` suffix targets the page's Markdown + # twin (DocsKit::Controller#render_page). + def link_line(link, base_url) + "- [#{link.label}](#{md_url(link.href, base_url)})" + end + + # href + ".md", absolutized against base_url when given. base_url has no + # trailing slash concerns here (hrefs are root-relative like "/docs/x"). + def md_url(href, base_url) + path = "#{href}.md" + return path unless base_url + + "#{base_url.chomp('/')}#{path}" + end + end +end diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 0e7c3e1..22260d2 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -72,6 +72,13 @@ def add_routes # pin html and defeat the .md route. route %(get "docs/:doc(.:format)" => "docs#show", as: :doc) route %(root "landings#show") + + # AI-readable docs (llmstxt.org), served from the registry by the gem's + # DocsKit::LlmsController — zero authoring. /llms.txt is the index; + # /llms-full.txt concatenates every page's Markdown twin. Thor's `route` + # skips a line already present, so re-running the generator is idempotent. + route %(get "/llms.txt" => "docs_kit/llms#index", as: :llms) + route %(get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full) end def create_css_build diff --git a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb index 0630db6..1630b61 100644 --- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb @@ -12,6 +12,10 @@ Rails.application.config.to_prepare do c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset] c.code_theme = "Rouge::Themes::Monokai" + # The one-line summary in /llms.txt (the llmstxt.org blockquote agents read + # first). Default nil omits the line; set it to describe your docs: + # c.tagline = "What this documentation covers, in one sentence." + # The topbar brand link points at "/" by default. Point it elsewhere if your # docs live under a subpath: # c.brand_href = "/docs" diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index 4dc2c6d..cb37c41 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -13,6 +13,18 @@ end end + describe "#tagline" do + it "defaults to nil (the llms.txt blockquote line is omitted)" do + expect(described_class.new.tagline).to be_nil + end + + it "is overridable so a site sets its llms.txt summary" do + DocsKit.configure { |c| c.tagline = "The shared Phlex chrome for docs sites." } + + expect(DocsKit.configuration.tagline).to eq("The shared Phlex chrome for docs sites.") + end + end + describe "#page_markdown_action" do it "defaults to true (every page shows the 'Markdown' affordance)" do expect(described_class.new.page_markdown_action).to be(true) diff --git a/spec/docs_kit/llms_controller_spec.rb b/spec/docs_kit/llms_controller_spec.rb new file mode 100644 index 0000000..704730a --- /dev/null +++ b/spec/docs_kit/llms_controller_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# The controller subclasses ActionController::Base, so it can't load in the +# standalone suite (no Rails request stack). Its text shaping is covered by +# spec/docs_kit/llms_text_spec.rb; here we prove the SHIPPED FILE is where Rails +# will autoload DocsKit::LlmsController from, and that the thin controller wires +# the builder + the Rails seams the way #full needs. The end-to-end constant +# load + request behavior is dogfooded against the docs/ app (see the PR). +# rubocop:disable RSpec/DescribeClass -- the class is Rails-only, can't constantize here +RSpec.describe "DocsKit::LlmsController (source wiring)" do + # app/controllers/docs_kit/llms_controller.rb → DocsKit::LlmsController under + # Rails' default inflector (docs_kit → DocsKit, llms_controller → LlmsController). + # The engine opts app/ out of the gem's zeitwerk loader (the superclass is + # Rails-only), so Rails' own autoloader owns this constant from this path. + let(:path) do + File.expand_path("../../app/controllers/docs_kit/llms_controller.rb", __dir__) + end + let(:source) { File.read(path) } + + it "ships at the path Rails autoloads DocsKit::LlmsController from" do + expect(File.exist?(path)).to be(true) + end + + it "declares DocsKit::LlmsController < ActionController::Base" do + expect(source).to include("module DocsKit") + expect(source).to include("class LlmsController < ActionController::Base") + end + + it "exposes the two llmstxt actions" do + expect(source).to match(/def index\b/) + expect(source).to match(/def full\b/) + end + + it "renders text/plain; charset=utf-8 (llms.txt is plain text, not markdown)" do + expect(source).to include('content_type: "text/plain; charset=utf-8"') + end + + it "builds every artifact through the pure DocsKit::LlmsText builder" do + expect(source).to include("DocsKit::LlmsText.index") + expect(source).to include("DocsKit::LlmsText.pages") + expect(source).to include("DocsKit::LlmsText.full") + end + + it "HTTP-caches on the gem version + body via stale?/etag" do + expect(source).to include("stale?(etag: [DocsKit::VERSION, body]") + end + + it "does not shadow ActionController::Base#config (forgery delegates to it)" do + # RequestForgeryProtection delegates allow_forgery_protection to #config, so + # a `def config` on the controller breaks csrf_meta_tags when #full renders a + # page's . Guard the regression: the DocsKit config reader is #docs_config. + expect(source).not_to match(/^\s*def config\b/) + expect(source).to include("def docs_config = DocsKit.configuration") + end +end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/docs_kit/llms_text_spec.rb b/spec/docs_kit/llms_text_spec.rb new file mode 100644 index 0000000..197f9d4 --- /dev/null +++ b/spec/docs_kit/llms_text_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +# DocsKit::LlmsText builds the two AI-readable artifacts from the registry + +# config, with ZERO Rails: given a fake registry (the same #nav_items / #all +# duck type a real DocsKit::Registry exposes) and a Configuration, it produces +# the llms.txt index string and joins per-page Markdown into llms-full.txt. The +# controller (which owns the Rails view context) renders each page to Markdown +# and hands the [title, md] pairs to .full — so all the text shaping is tested +# here without booting Rails. +RSpec.describe DocsKit::LlmsText do + # A NavItem-ish link: #href + #label (what #nav_items returns per group). + def link(href:, label:) + Struct.new(:href, :label, keyword_init: true).new(href:, label:) + end + + # A page-ish entry: #title / #href / #view_class (what #all returns). A nil + # view_class == an unwritten page, excluded everywhere. + def entry(title:, href:, view_class:) + Struct.new(:title, :href, :view_class, keyword_init: true).new(title:, href:, view_class:) + end + + # A fake registry standing in for a DocsKit::Registry class: #nav_items groups + # authored links; #all lists authored + unauthored entries in registry order. + def registry(nav_items:, all: []) + Class.new do + define_singleton_method(:nav_items) { nav_items } + define_singleton_method(:all) { all } + end + end + + let(:doc_registry) do + registry( + nav_items: { + "Getting started" => [ + link(href: "/docs/overview", label: "Overview"), + link(href: "/docs/installation", label: "Installation") + ], + "Reference" => [ + link(href: "/docs/components", label: "Components") + ] + } + ) + end + + def configure(**opts) + DocsKit.configure do |c| + c.brand = opts.fetch(:brand, "docs-kit") + c.tagline = opts[:tagline] + c.nav_registries = opts.fetch(:nav_registries, { "Docs" => doc_registry }) + end + DocsKit.configuration + end + + describe ".index" do + subject(:index) { described_class.index(configure(tagline: "Shared docs chrome."), base_url: "https://acme.dev") } + + it "opens with the brand as an H1" do + expect(index).to start_with("# docs-kit\n") + end + + it "renders the tagline as a blockquote under the H1" do + expect(index).to include("\n> Shared docs chrome.\n") + end + + it "renders each registry group as an H2 section" do + expect(index).to include("## Getting started").and include("## Reference") + end + + it "lists each authored page as a Markdown link to its absolute .md twin" do + expect(index).to include("- [Overview](https://acme.dev/docs/overview.md)") + expect(index).to include("- [Installation](https://acme.dev/docs/installation.md)") + expect(index).to include("- [Components](https://acme.dev/docs/components.md)") + end + + it "preserves registry order for groups and links" do + expect(index.index("## Getting started")).to be < index.index("## Reference") + expect(index.index("Overview")).to be < index.index("Installation") + end + + it "renders each section as a tight bullet list (no blank line between bullets)" do + # Section heading is followed immediately by its bullets, one per line. + expect(index).to include( + "## Getting started\n" \ + "- [Overview](https://acme.dev/docs/overview.md)\n" \ + "- [Installation](https://acme.dev/docs/installation.md)" + ) + end + + context "when the tagline is nil (default)" do + subject(:index) { described_class.index(configure(tagline: nil), base_url: "https://acme.dev") } + + it "omits the blockquote line entirely" do + expect(index).not_to include(">") + # H1 is immediately followed by the first section, no blank blockquote. + expect(index).to start_with("# docs-kit\n\n## Getting started") + end + end + + context "with multiple registries" do + subject(:index) do + described_class.index( + configure(nav_registries: { "Docs" => doc_registry, "API" => api_registry }), + base_url: "https://acme.dev" + ) + end + + let(:api_registry) do + registry(nav_items: { "Endpoints" => [link(href: "/api/users", label: "Users")] }) + end + + it "emits every registry's groups in config order" do + expect(index.index("## Getting started")).to be < index.index("## Endpoints") + expect(index).to include("- [Users](https://acme.dev/api/users.md)") + end + end + + context "with an empty registry (all pages unwritten)" do + subject(:index) do + described_class.index( + configure(nav_registries: { "Docs" => registry(nav_items: {}) }), + base_url: "https://acme.dev" + ) + end + + it "renders a valid index with no sections (never an empty ## group)" do + expect(index).to start_with("# docs-kit") + expect(index).not_to include("##") + end + end + + it "does not require a base_url (relative .md links when omitted)" do + index = described_class.index(configure(tagline: nil)) + + expect(index).to include("- [Overview](/docs/overview.md)") + end + end + + describe ".pages" do + subject(:pages) { described_class.pages(config) } + + let(:written) { Object.new } + let(:config) do + configure( + nav_registries: { + "Docs" => registry( + nav_items: {}, + all: [ + entry(title: "Overview", href: "/docs/overview", view_class: written), + entry(title: "Unwritten", href: "/docs/unwritten", view_class: nil), + entry(title: "Installation", href: "/docs/installation", view_class: written) + ] + ) + } + ) + end + + it "returns only authored pages (a resolvable view_class), in registry order" do + expect(pages.map(&:title)).to eq(%w[Overview Installation]) + end + end + + describe ".full" do + it "concatenates each page as an H1 title + its Markdown, separated by ---" do + out = described_class.full( + configure(tagline: nil), + [["Overview", "Overview body."], ["Installation", "Install body."]] + ) + + expect(out).to eq( + "# Overview\n\nOverview body.\n\n---\n\n# Installation\n\nInstall body." + ) + end + + it "returns an empty string when there are no pages" do + expect(described_class.full(configure, [])).to eq("") + end + end +end diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index b60b0ac..6f1611c 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -150,6 +150,22 @@ def silence_stream expect(routes).to include("(.:format)") expect(routes).not_to match(/defaults:\s*\{\s*format:/) end + + it "adds the llms.txt + llms-full.txt routes (AI-readable docs)" do + routes = read("config/routes.rb") + + expect(routes).to include(%(get "/llms.txt" => "docs_kit/llms#index")) + expect(routes).to include(%(get "/llms-full.txt" => "docs_kit/llms#full")) + end + + it "does not duplicate routes on re-run (idempotent)" do + run_generator # second invocation against the same destination + + routes = read("config/routes.rb") + expect(routes.scan(%(get "/llms.txt" => "docs_kit/llms#index")).size).to eq(1) + expect(routes.scan(%(get "/llms-full.txt" => "docs_kit/llms#full")).size).to eq(1) + expect(routes.scan(%(get "docs/:doc(.:format)" => "docs#show", as: :doc)).size).to eq(1) + end end describe "controller injection (include_controller_helper)" do