docs-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +939 -0
  5. data/app/components/docs_ui/brand_mark.rb +88 -0
  6. data/app/components/docs_ui/callout.rb +37 -0
  7. data/app/components/docs_ui/code.rb +123 -0
  8. data/app/components/docs_ui/endpoint.rb +44 -0
  9. data/app/components/docs_ui/error_table.rb +72 -0
  10. data/app/components/docs_ui/example.rb +102 -0
  11. data/app/components/docs_ui/field_table.rb +46 -0
  12. data/app/components/docs_ui/header.rb +30 -0
  13. data/app/components/docs_ui/icon.rb +65 -0
  14. data/app/components/docs_ui/json_response.rb +46 -0
  15. data/app/components/docs_ui/markdown.rb +187 -0
  16. data/app/components/docs_ui/markdown_action.rb +45 -0
  17. data/app/components/docs_ui/on_this_page.rb +104 -0
  18. data/app/components/docs_ui/open_api_operation.rb +126 -0
  19. data/app/components/docs_ui/page.rb +83 -0
  20. data/app/components/docs_ui/page_helpers.rb +52 -0
  21. data/app/components/docs_ui/prop_table.rb +43 -0
  22. data/app/components/docs_ui/prose.rb +30 -0
  23. data/app/components/docs_ui/request_example.rb +85 -0
  24. data/app/components/docs_ui/search_box.rb +106 -0
  25. data/app/components/docs_ui/search_results.rb +95 -0
  26. data/app/components/docs_ui/section.rb +94 -0
  27. data/app/components/docs_ui/shell.rb +161 -0
  28. data/app/components/docs_ui/sidebar.rb +106 -0
  29. data/app/components/docs_ui/table.rb +64 -0
  30. data/app/components/docs_ui/theme_switcher.rb +46 -0
  31. data/app/components/docs_ui/topbar_links.rb +42 -0
  32. data/app/controllers/docs_kit/llms_controller.rb +76 -0
  33. data/app/controllers/docs_kit/mcp_controller.rb +60 -0
  34. data/app/controllers/docs_kit/search_controller.rb +72 -0
  35. data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
  36. data/config/importmap.rb +15 -0
  37. data/config/rubocop/docs_kit.yml +24 -0
  38. data/exe/docs-kit +80 -0
  39. data/lib/docs-kit.rb +5 -0
  40. data/lib/docs_kit/api_client.rb +52 -0
  41. data/lib/docs_kit/api_request.rb +66 -0
  42. data/lib/docs_kit/api_templates.rb +92 -0
  43. data/lib/docs_kit/configuration.rb +485 -0
  44. data/lib/docs_kit/controller.rb +47 -0
  45. data/lib/docs_kit/engine.rb +49 -0
  46. data/lib/docs_kit/llms_text.rb +105 -0
  47. data/lib/docs_kit/markdown_export/blocks.rb +160 -0
  48. data/lib/docs_kit/markdown_export/inline.rb +95 -0
  49. data/lib/docs_kit/markdown_export/table.rb +53 -0
  50. data/lib/docs_kit/markdown_export.rb +92 -0
  51. data/lib/docs_kit/mcp_server.rb +128 -0
  52. data/lib/docs_kit/mcp_tools.rb +118 -0
  53. data/lib/docs_kit/nav_item.rb +22 -0
  54. data/lib/docs_kit/open_api/document.rb +91 -0
  55. data/lib/docs_kit/open_api/operation.rb +213 -0
  56. data/lib/docs_kit/open_api/schema.rb +178 -0
  57. data/lib/docs_kit/open_api.rb +55 -0
  58. data/lib/docs_kit/registry.rb +152 -0
  59. data/lib/docs_kit/rubocop.rb +19 -0
  60. data/lib/docs_kit/search_hit.rb +28 -0
  61. data/lib/docs_kit/search_index/snippet.rb +65 -0
  62. data/lib/docs_kit/search_index.rb +169 -0
  63. data/lib/docs_kit/shortcut.rb +99 -0
  64. data/lib/docs_kit/templates/new_site.rb +175 -0
  65. data/lib/docs_kit/topbar_link.rb +39 -0
  66. data/lib/docs_kit/version.rb +5 -0
  67. data/lib/docs_kit.rb +72 -0
  68. data/lib/generators/docs_kit/install/USAGE +15 -0
  69. data/lib/generators/docs_kit/install/install_generator.rb +447 -0
  70. data/lib/generators/docs_kit/install/sync_report.rb +64 -0
  71. data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
  72. data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
  73. data/lib/generators/docs_kit/install/templates/build-css +34 -0
  74. data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
  75. data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
  76. data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
  77. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
  78. data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
  79. data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
  80. data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
  81. data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
  82. data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
  83. data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
  84. data/lib/generators/docs_kit/page/USAGE +26 -0
  85. data/lib/generators/docs_kit/page/page_generator.rb +127 -0
  86. data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
  87. data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
  88. data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
  89. metadata +253 -0
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # Build the Tailwind/daisyUI CSS with the gem source paths resolved at build time.
3
+ #
4
+ # Tailwind must scan the Ruby that emits class names — including the daisyui and
5
+ # docs-kit GEMS, whose install dir varies by environment (a global gem dir in dev,
6
+ # a vendored .bundle/gems in Docker). Resolve them with `bundle show` and write
7
+ # the @source globs into a generated file the CSS imports. Fail fast if a required
8
+ # gem can't be resolved — a silently-missing @source ships an unstyled site.
9
+ set -euo pipefail
10
+
11
+ cd "$(dirname "$0")/.."
12
+
13
+ GENERATED="app/assets/stylesheets/tailwind.sources.css"
14
+
15
+ sources=""
16
+ for gem in daisyui docs-kit; do
17
+ if ! path="$(bundle show "$gem" 2>/dev/null)"; then
18
+ echo "bin/build-css: could not resolve gem path for '$gem' (bundle show failed)." >&2
19
+ echo " Tailwind would drop its component classes from the build. Aborting." >&2
20
+ exit 1
21
+ fi
22
+ sources+="@source \"${path}/**/*.rb\";"$'\n'
23
+ done
24
+
25
+ {
26
+ echo "/* GENERATED by bin/build-css — do not edit. Gem @source globs for Tailwind. */"
27
+ printf '%s' "$sources"
28
+ } > "$GENERATED"
29
+
30
+ MODE="${1:---minify}"
31
+ exec bunx @tailwindcss/cli \
32
+ -i ./app/assets/stylesheets/application.tailwind.css \
33
+ -o ./app/assets/builds/application.css \
34
+ "$MODE"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Build the daisyUI/Tailwind stylesheet into app/assets/builds/application.css so
4
+ # Propshaft can serve it. Runs as part of assets:precompile (production/Docker);
5
+ # `bin/dev` runs the watch variant in development.
6
+ namespace :css do
7
+ desc "Build the Tailwind + daisyUI stylesheet via bun"
8
+ task build: :environment do
9
+ sh "bun run build:css"
10
+ end
11
+ end
12
+
13
+ Rake::Task["assets:precompile"].enhance(["css:build"]) if Rake::Task.task_defined?("assets:precompile")
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # In-memory registry of the reference docs. One line per page — slug and view
4
+ # derive from the title (both overridable), and the sidebar nav derives from
5
+ # this registry with zero extra code (see config/initializers/docs_kit.rb's
6
+ # `nav_registries`). Add a page with `rails g docs_kit:page "Title" --group=…`,
7
+ # which appends the `page` line here and writes the class under
8
+ # app/views/docs/pages/.
9
+ #
10
+ # Uses DocsKit::Registry for the shared all/from_slug/grouped/nav_items API.
11
+ class Doc
12
+ extend DocsKit::Registry
13
+ path_prefix "/docs"
14
+ view_namespace "Views::Docs::Pages"
15
+
16
+ page "Installation", group: "Guide"
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Renders a hand-authored doc page from the Doc registry. `render_page` (from
4
+ # DocsKit::Controller) renders the Phlex page with layout: false, since the page
5
+ # composes DocsUI::Shell which IS the full HTML document.
6
+ class DocsController < ApplicationController
7
+ def show
8
+ doc = Doc.from_slug(params[:doc])
9
+ view = doc&.view_class
10
+ return head :not_found unless view
11
+
12
+ render_page view.new
13
+ end
14
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # docs-kit configuration — everything that makes this site look like YOUR docs.
4
+ # The shared chrome (Shell/Sidebar/ThemeSwitcher/Code/Page) comes from the gem;
5
+ # only this config differs per site. The `themes` MUST match the
6
+ # @plugin "daisyui" { themes: ... } block in
7
+ # app/assets/stylesheets/application.tailwind.css.
8
+ Rails.application.config.to_prepare do
9
+ DocsKit.configure do |c|
10
+ c.brand = "<%= app_brand %>"
11
+ c.title_suffix = "<%= app_brand %>"
12
+ c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset]
13
+ c.code_theme = "Rouge::Themes::Monokai"
14
+
15
+ # The one-line summary in /llms.txt (the llmstxt.org blockquote agents read
16
+ # first). Default nil omits the line; set it to describe your docs:
17
+ # c.tagline = "What this documentation covers, in one sentence."
18
+
19
+ # The topbar brand link points at "/" by default. Point it elsewhere if your
20
+ # docs live under a subpath:
21
+ # c.brand_href = "/docs"
22
+
23
+ # Repo/social links in the topbar, next to the theme switcher. Each renders
24
+ # as an icon-only button; `icon` is a shipped brand mark (:github, :gitlab,
25
+ # :discord, :x, :rubygems, :bluesky, :mastodon, :slack, :whatsapp, :telegram,
26
+ # :linkedin, :youtube, :reddit, :stackoverflow) or any lucide icon name.
27
+ # External links open in a new tab. Defaults to [] (no links).
28
+ # c.topbar_links = [
29
+ # { href: "https://github.com/OWNER/REPO", label: "GitHub", icon: :github },
30
+ # # { href: "https://discord.gg/INVITE", label: "Discord", icon: :discord },
31
+ # ]
32
+
33
+ # Code blocks use one Rouge theme by default. To keep them readable in BOTH
34
+ # light and dark daisyUI themes, set a light base + a dark override — the dark
35
+ # theme's CSS is scoped under [data-theme=X] for each shipped dark theme, so
36
+ # the switcher restyles code blocks with no JS and no flash:
37
+ # c.code_theme = "Rouge::Themes::Github" # light themes
38
+ # c.code_theme_dark = "Rouge::Themes::Monokai" # dark themes
39
+ # c.dark_themes = %w[my-dark] # only if you ship custom dark themes
40
+
41
+ # Any language Rouge knows works in code blocks out of the box. Add friendly
42
+ # aliases/labels here if you use custom names:
43
+ # c.code_lexer_aliases = { curl: "console" }
44
+ # c.code_language_labels = { elixir: "Elixir" }
45
+
46
+ # Every page is ALSO available as Markdown: GET /docs/<page>.md returns a
47
+ # faithful GFM twin of the rendered page (for LLMs / copy-paste), and a
48
+ # "Markdown" masthead action links to it (JS enhances it into copy-to-clipboard).
49
+ # Set false to hide the action site-wide (the .md route still works):
50
+ # c.page_markdown_action = false
51
+
52
+ # Search is on by default — the topbar grows a search box (a JS-off GET form
53
+ # that docs-nav enhances into a command palette), served by
54
+ # DocsKit::SearchController at the route the install generator drew. Hide it,
55
+ # point it at a moved route, or change the keyboard shortcuts:
56
+ # c.search = false # hide the topbar search box site-wide
57
+ # c.search_path = "/docs/search" # default; match your route if you move it
58
+ # c.search_shortcuts = ["/", "mod+k"] # default; "mod" = ⌘ on mac, Ctrl elsewhere.
59
+ # Each is a bare key ("/", "s") or a chord ("mod+k", "ctrl+shift+f"); [] binds none.
60
+
61
+ # OPTIONAL: API reference from your snippets. DocsUI::RequestExample renders
62
+ # one request declaration as a code tab per client; these knobs point every
63
+ # snippet at your host with your auth. See README → "API docs".
64
+ # c.api_base_url = "https://api.acme.com" # prefixed onto each path
65
+ # c.api_auth_header = "Authorization: Bearer sk_..." # example auth line
66
+ # c.api_clients = { cli: DocsKit::ApiClient.new(...) } # SDK/CLI tabs
67
+
68
+ # OPTIONAL: the OpenAPI bridge. Point c.openapi at an OpenAPI 3.x spec and a
69
+ # docs page can render a whole endpoint with `operation "createInvoice"` — the
70
+ # Endpoint badge, field/error tables, request tabs, and response are derived
71
+ # from the spec (zero hand-restatement). Off (nil) by default; a String/
72
+ # Pathname path (.json → JSON, else YAML) or an already-parsed Hash.
73
+ # See README → "OpenAPI bridge".
74
+ # c.openapi = Rails.root.join("openapi.yaml")
75
+
76
+ # OPTIONAL: expose your docs to AI agents over MCP (Model Context Protocol) —
77
+ # a read-only endpoint (search_docs / get_page / list_pages) at POST /mcp that
78
+ # Claude Code / Claude.ai / Cursor can add as tools. It's OFF until you add
79
+ # `gem "mcp"` to your Gemfile AND uncomment the /mcp route in config/routes.rb
80
+ # (see the commented lines there). `c.mcp` defaults to true, so once the gem +
81
+ # route are present the endpoint is live; set it false to keep it off even
82
+ # then. README → "Add your docs to an agent (MCP)".
83
+ # c.mcp = false
84
+
85
+ # The sidebar nav derives from the registry — one heading → one registry.
86
+ # Each registry's authored pages become NavItems automatically (an unwritten
87
+ # page is skipped, so no dead links). For bespoke nav (interleaved
88
+ # registries, custom subgroups) set a `c.nav` lambda instead; it wins.
89
+ c.nav_registries = { "Docs" => Doc }
90
+ end
91
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A sample guide page. Zeitwerk resolves this compact class reference through
4
+ # the directory-implied namespaces (app/views/docs/pages/ → Views::Docs::Pages),
5
+ # so there's no need for the 4-level nested-module ceremony. Subclass
6
+ # DocsUI::Page, set the title (+ optional eyebrow/lead), and build the body from
7
+ # the DocsUI kit (Section/Code) and Markdown islands (md). The "On this page"
8
+ # TOC + scroll-spy are automatic (config default).
9
+ class Views::Docs::Pages::Installation < DocsUI::Page
10
+ title "Installation"
11
+ eyebrow "Guide"
12
+
13
+ def lead = "Add the gem and render your first page."
14
+
15
+ def content
16
+ DocsUI::Section("Add the gem", description: "One line in your Gemfile.") do
17
+ md <<~'MD'
18
+ docs-kit ships the shared Phlex chrome — configure it once.
19
+ MD
20
+ DocsUI::Code(<<~RUBY, filename: "Gemfile")
21
+ gem "docs-kit"
22
+ RUBY
23
+ end
24
+
25
+ DocsUI::Section("Configure") do
26
+ md <<~'MD'
27
+ Set your brand, themes, and nav:
28
+ MD
29
+ DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb")
30
+ DocsKit.configure do |c|
31
+ c.brand = "<%= app_brand %>"
32
+ c.themes = %w[dark light]
33
+ end
34
+ RUBY
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Views
4
+ module Landings
5
+ # The home page. Renders inside DocsUI::Shell (the full document + drawer
6
+ # shell); links to the authored docs.
7
+ class Show < Phlex::HTML
8
+ include Phlex::Rails::Helpers::Routes
9
+
10
+ def view_template
11
+ render DocsUI::Shell.new do
12
+ div(class: "prose max-w-none") do
13
+ h1 { "<%= app_brand %>" }
14
+ p { "Documentation, built with docs-kit." }
15
+ ul do
16
+ Doc.all.select(&:view_class).each do |doc|
17
+ li { a(href: "/docs/#{doc.slug}", class: "link") { doc.title } }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LandingsController < ApplicationController
4
+ def show
5
+ render_page Views::Landings::Show.new
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Phlex autoload namespaces for this docs site:
4
+ # app/views → Views:: (pages, e.g. Views::Docs::Pages::Installation)
5
+ # app/components → Components:: (a Phlex::Kit; your own components, if any)
6
+ module Views
7
+ end
8
+
9
+ module Components
10
+ extend Phlex::Kit
11
+ end
12
+
13
+ Rails.autoloaders.main.push_dir(Rails.root.join("app", "views"), namespace: Views)
14
+ Rails.autoloaders.main.push_dir(Rails.root.join("app", "components"), namespace: Components)
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # docs-kit's chrome renders lucide icons via rails_icons. Set the default library
4
+ # and sync the icon set once:
5
+ #
6
+ # bin/rails g rails_icons:sync --library=lucide # syncs the default (lucide) set
7
+ #
8
+ # (The chrome uses: menu, palette, list, file-code, search, info, lightbulb,
9
+ # triangle-alert. Syncing the whole lucide set covers those + any you add.)
10
+ RailsIcons.configure do |config|
11
+ config.default_library = "lucide"
12
+ end
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: write-docs-page
3
+ description: "Write, add, or update a documentation page in this docs-kit site. Use when asked to document a feature, endpoint, class, or workflow, or to add or edit a page under app/views/docs/pages/. Scaffolds with `rails g docs_kit:page`, writes Markdown-first content, and runs the verification gates."
4
+ ---
5
+
6
+ # Write a docs page
7
+
8
+ This is a [docs-kit](https://github.com/mhenrixon/docs-kit) site (<%= app_brand %>).
9
+ Every page is a `DocsUI::Page` subclass; the shell, sidebar, "On this page" TOC,
10
+ search, and the `.md` twin all come free. Your job is to scaffold a page and
11
+ write its `#content` — never hand-write HTML or daisyUI markup.
12
+
13
+ The full authoring contract is in the repo's `AGENTS.md`. This is the recipe.
14
+
15
+ ## 1. Gather the subject
16
+
17
+ Identify exactly what to document (the code, endpoint, or workflow) and where it
18
+ belongs in the sidebar (the `--group`). Read the relevant source first — do not
19
+ invent behavior. If the subject is an HTTP endpoint, prefer `DocsUI::RequestExample`
20
+ / `DocsUI::FieldTable`; if it's a Ruby API, prefer `DocsUI::PropTable`. If the
21
+ site has `c.openapi` set (an OpenAPI 3.x spec), a single `operation "operationId"`
22
+ renders the whole endpoint (badge + tables + request tabs + response) from the
23
+ spec — no hand-restatement; reach for it before hand-authoring.
24
+
25
+ ## 2. Scaffold (one command)
26
+
27
+ ```bash
28
+ rails g docs_kit:page "Page Title" --group=Guide
29
+ ```
30
+
31
+ This writes `app/views/docs/pages/<slug>.rb` **and** injects the required
32
+ `page "…"` registry line into `app/models/doc.rb`. Overrides: `--slug`, `--view`,
33
+ `--eyebrow`, `--registry`. If it reports a legacy `entries [...]` registry, add
34
+ the printed line by hand.
35
+
36
+ ## 3. Write `#content` — Markdown first
37
+
38
+ - Set `title`, `eyebrow`, and a one-sentence `lead`.
39
+ - One `DocsUI::Section("…")` per part of the page — **Sections own structure and
40
+ the TOC.** Never use a Markdown `##` for page structure.
41
+ - Prose is `md <<~'MD' … MD` — a **single-quoted** heredoc (no escaping, no
42
+ interpolation; Phlex escapes author text). Markdown `###` is only for
43
+ sub-headings inside a Section.
44
+ - Reference material: `DocsUI::PropTable`, `DocsUI::FieldTable`,
45
+ `DocsUI::RequestExample`, `DocsUI::Code(source, filename:)`,
46
+ `DocsUI::Callout(:note | :tip | :warning)`.
47
+ - OpenAPI-backed endpoints (when `c.openapi` is set): `operation "createInvoice"`
48
+ renders one operation as a full endpoint reference; append prose with a block —
49
+ `operation "createInvoice" do |op| op.md("…") end` — and filter tabs with
50
+ `clients: %i[curl ruby]`.
51
+
52
+ ```ruby
53
+ class Views::Docs::Pages::PageTitle < DocsUI::Page
54
+ title "Page Title"
55
+ eyebrow "Guide"
56
+
57
+ def lead = "One sentence describing the page."
58
+
59
+ def content
60
+ DocsUI::Section("Overview", description: "What this covers.") do
61
+ md <<~'MD'
62
+ Prose as **Markdown**. Fenced ```ruby``` blocks highlight; `inline code`,
63
+ lists, links, and GFM tables all render styled.
64
+ MD
65
+ end
66
+ end
67
+ end
68
+ ```
69
+
70
+ ## 4. Self-review against the checklist
71
+
72
+ - [ ] The `page "…"` registry line exists (the generator adds it).
73
+ - [ ] Structure is `DocsUI::Section`s, not Markdown `##` headings.
74
+ - [ ] Prose uses `md <<~'MD'` (single-quoted); no `html_safe`, no `raw`.
75
+ - [ ] No hand-written HTML/daisyUI markup, no per-feature Stimulus controller.
76
+ - [ ] Reads correctly with JavaScript off (the server renders it fully).
77
+ - [ ] Any new theme is in both `c.themes` and the Tailwind `@plugin` block.
78
+ - [ ] No inline `rubocop:disable` to force layout.
79
+
80
+ ## 5. Run the gates
81
+
82
+ ```bash
83
+ bundle exec rspec && bundle exec rubocop
84
+ bun run build:css # only if you added classes the CSS must scan
85
+ ```
86
+
87
+ Then render locally (`bin/dev`, open `/docs/<slug>`) and confirm it reads well.
88
+ For depth on any idiom, read the live [Authoring pages](/docs/authoring) doc.
@@ -0,0 +1,26 @@
1
+ Description:
2
+ Scaffold one docs page: a Phlex page class under app/views/docs/pages/ AND
3
+ its one-line registry entry, both derived from the title, in one command.
4
+ The unit of work for "add a docs page" drops to this command plus writing
5
+ content.
6
+
7
+ The page class uses the compact form
8
+ `class Views::Docs::Pages::Title < DocsUI::Page` (Zeitwerk resolves it
9
+ through the directory-implied namespaces) with title/eyebrow/lead and a
10
+ starter Section containing a Markdown island.
11
+
12
+ The registry line `page "Title", group: "Group"` is injected into the
13
+ Registry-v2 class (default: Doc). A legacy hash-`entries` registry is left
14
+ untouched with an instruction printed instead of corrupting it.
15
+
16
+ Idempotent — re-running does not duplicate the registry line or clobber the
17
+ page file (in --skip mode).
18
+
19
+ Example:
20
+ rails generate docs_kit:page "Getting Started" --group=Guide
21
+
22
+ rails generate docs_kit:page "OAuth" --group=Guide --slug=auth --view=OauthGuide
23
+ rails generate docs_kit:page "Metrics" --group=Reference --eyebrow="Advanced" --registry=Doc
24
+
25
+ After running:
26
+ # edit app/views/docs/pages/getting_started.rb — write your content
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module DocsKit
7
+ module Generators
8
+ # `rails g docs_kit:page TITLE --group=GROUP`
9
+ #
10
+ # Scaffolds one docs page — the Phlex page class AND its one-line registry
11
+ # entry, both derived from the title — so adding a page is one command plus
12
+ # writing content, not the old ceremony (a 4-level-nested class in one file
13
+ # plus a hand-synced registry line in another, either half easy to forget).
14
+ #
15
+ # rails g docs_kit:page "Getting Started" --group=Guide
16
+ # → app/views/docs/pages/getting_started.rb (compact class form)
17
+ # → injects `page "Getting Started", group: "Guide"` into Doc
18
+ #
19
+ # Every derivation is overridable: --slug, --view, --eyebrow, --registry.
20
+ # A legacy hash-`entries` registry is left untouched (an instruction is
21
+ # printed instead of corrupting it), and re-running is idempotent.
22
+ class PageGenerator < ::Rails::Generators::Base
23
+ source_root File.expand_path("templates", __dir__)
24
+
25
+ argument :title, type: :string,
26
+ desc: %(The page title, e.g. "Getting Started")
27
+
28
+ class_option :group, type: :string, default: "Guide",
29
+ desc: "The sidebar group heading"
30
+ class_option :slug, type: :string,
31
+ desc: "URL slug (default: the title parameterized)"
32
+ class_option :view, type: :string,
33
+ desc: "Page class basename (default: the title camelized)"
34
+ class_option :eyebrow, type: :string,
35
+ desc: "Eyebrow above the title (default: the group)"
36
+ class_option :registry, type: :string, default: "Doc",
37
+ desc: "Registry class to register the page in"
38
+
39
+ def create_page_file
40
+ template "page.rb.erb", "app/views/docs/pages/#{view_name.underscore}.rb"
41
+ end
42
+
43
+ def register_page
44
+ path = registry_path
45
+ rel = relative(path)
46
+ line = registry_line
47
+ return say_status(:skip, "#{rel} not found — add `#{line}` manually", :yellow) unless File.exist?(path)
48
+
49
+ source = File.read(path)
50
+ return say_status(:skip, legacy_instruction(rel), :yellow) if legacy_entries?(source)
51
+ return say_status(:identical, "#{rel} already registers #{title.inspect}", :blue) if source.include?(line)
52
+
53
+ inject_into_file path, " #{line}\n", after: registry_anchor(source)
54
+ end
55
+
56
+ private
57
+
58
+ # The by-hand instruction printed for a legacy hash-`entries` registry the
59
+ # generator won't touch (injecting a `page` line would corrupt it).
60
+ def legacy_instruction(rel)
61
+ "#{rel} uses the legacy `entries [...]` form — add this entry by hand:\n " \
62
+ "{ slug: #{slug.inspect}, title: #{title.inspect}, " \
63
+ "group: #{options[:group].inspect}, view: #{view_name.inspect} }"
64
+ end
65
+
66
+ # The Phlex page class basename (e.g. "GettingStarted"). --view wins,
67
+ # else camelize the title (with "_" word boundaries so hyphens don't
68
+ # survive into the constant).
69
+ def view_name
70
+ options[:view].presence || title.parameterize(separator: "_").camelize
71
+ end
72
+
73
+ # The URL slug. --slug wins, else the title parameterized.
74
+ def slug
75
+ options[:slug].presence || title.parameterize
76
+ end
77
+
78
+ # The eyebrow above the title. --eyebrow wins, else the group.
79
+ def eyebrow
80
+ options[:eyebrow].presence || options[:group]
81
+ end
82
+
83
+ # The one-line registry entry, with only the overrides that differ from
84
+ # the derived defaults spelled out (slug when it isn't the parameterized
85
+ # title; view when it isn't the camelized title).
86
+ def registry_line
87
+ ([%(page #{title.inspect}), %(group: #{options[:group].inspect})] + override_kwargs).join(", ")
88
+ end
89
+
90
+ # The explicit slug:/view: keywords, present only when overridden.
91
+ def override_kwargs
92
+ kwargs = []
93
+ kwargs << %(slug: #{slug.inspect}) if slug != title.parameterize
94
+ kwargs << %(view: #{view_name.inspect}) if view_name != title.parameterize(separator: "_").camelize
95
+ kwargs
96
+ end
97
+
98
+ # A registry using the v2 `page` DSL has (or will have) `page` lines. The
99
+ # legacy form declares a hash `entries [...]` array and no `page` line.
100
+ def legacy_entries?(source)
101
+ source.match?(/^\s*entries\s*\[/) && !source.match?(/^\s*page\s+["']/)
102
+ end
103
+
104
+ # Inject after the last existing `page` line so ordering lands at the end
105
+ # of the group; else after view_namespace/path_prefix; else after the
106
+ # `extend DocsKit::Registry` line.
107
+ def registry_anchor(source)
108
+ case source
109
+ when /^\s*page\s+["']/
110
+ /^\s*page .*\n(?!\s*page )/
111
+ when /^\s*view_namespace\s/
112
+ /^\s*view_namespace .*\n/
113
+ when /^\s*path_prefix\s/
114
+ /^\s*path_prefix .*\n/
115
+ else
116
+ /extend DocsKit::Registry\n/
117
+ end
118
+ end
119
+
120
+ def registry_path
121
+ File.join(destination_root, "app/models/#{options[:registry].underscore}.rb")
122
+ end
123
+
124
+ def relative(path) = path.sub("#{destination_root}/", "")
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Zeitwerk resolves this compact reference through the directory-implied
4
+ # namespaces (app/views/docs/pages/ → Views::Docs::Pages), so there's no need
5
+ # for the 4-level nested-module ceremony.
6
+ class Views::Docs::Pages::<%= view_name %> < DocsUI::Page
7
+ title "<%= title %>"
8
+ eyebrow "<%= eyebrow %>"
9
+
10
+ def lead = "A one-sentence summary of this page."
11
+
12
+ def content
13
+ DocsUI::Section("Overview") do
14
+ md <<~'MD'
15
+ Write your content here as Markdown — headings, lists, `inline code`,
16
+ and fenced code blocks all render as prose. Add more `DocsUI::Section`s
17
+ for each part of the page; each becomes an "On this page" TOC entry.
18
+ MD
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DocsKit
6
+ # Flags an ESCAPED interpolation (`\#{...}`) inside a double-quoted heredoc
7
+ # and steers to the single-quoted delimiter (`<<~'RUBY'`), where `#{...}` is
8
+ # already literal so no backslash is needed.
9
+ #
10
+ # Docs pages constantly embed Ruby examples that themselves contain
11
+ # `#{...}`. In a double-quoted heredoc every one of those has to be escaped
12
+ # as `\#{...}` or Ruby interpolates it — the recurring "escape tax" every
13
+ # audited docs site paid. A single-quoted heredoc delimiter turns the whole
14
+ # body literal, so the examples read exactly as they will render.
15
+ #
16
+ # Ruby interpolates three sigils in a double-quoted string — `#{expr}`,
17
+ # `#@ivar` (also `#@@cvar`), and `#$global` — so the cop treats all three
18
+ # escape forms (`\#{`, `\#@`, `\#$`) as the escape tax, and a LIVE (unescaped)
19
+ # occurrence of any of them blocks autocorrection.
20
+ #
21
+ # @example
22
+ # # bad — escape tax
23
+ # source = <<~RUBY
24
+ # puts "hello \#{name}"
25
+ # RUBY
26
+ #
27
+ # # good — single-quoted delimiter, `#{...}` is literal
28
+ # source = <<~'RUBY'
29
+ # puts "hello #{name}"
30
+ # RUBY
31
+ #
32
+ # Autocorrection is UNSAFE and only offered when the heredoc has no LIVE
33
+ # (unescaped) interpolation: switching the delimiter to single-quoted would
34
+ # freeze a live interpolation into literal text, changing behaviour. When a
35
+ # live interpolation is present the cop reports but leaves the fix to a human.
36
+ class EscapedInterpolationInHeredoc < Base
37
+ extend AutoCorrector
38
+
39
+ MSG = "Use a single-quoted heredoc delimiter (`%<delimiter>s`) so " \
40
+ "`\#{...}` is literal without escaping."
41
+ MSG_LIVE = "#{MSG} This heredoc also has a live interpolation — fix by hand.".freeze
42
+
43
+ # A backslash directly in front of an interpolation opener. Ruby opens an
44
+ # interpolation with `#{`, `#@` (ivar/cvar), or `#$` (global), so an escape
45
+ # is a backslash + `#` + one of those sigil characters. The lookahead keeps
46
+ # the sigil out of the match, so de-escaping only strips the backslash.
47
+ ESCAPED_INTERPOLATION = /\\#(?=[{@$])/
48
+
49
+ def on_str(node)
50
+ check_heredoc(node)
51
+ end
52
+
53
+ # A heredoc with a live `#{...}` parses as a dstr; the escaped ones inside
54
+ # it still show up in the body source. Same check.
55
+ def on_dstr(node)
56
+ check_heredoc(node)
57
+ end
58
+
59
+ private
60
+
61
+ def check_heredoc(node)
62
+ return unless node.heredoc?
63
+
64
+ opening = node.loc.expression
65
+ return if single_quoted?(opening.source)
66
+ return unless escaped_interpolation?(node)
67
+
68
+ live = live_interpolation?(node)
69
+ add_offense(opening, message: message(opening.source, live: live)) do |corrector|
70
+ next if live # unsafe to autocorrect — a live #{...} would freeze
71
+
72
+ autocorrect(corrector, node, opening)
73
+ end
74
+ end
75
+
76
+ def message(delimiter, live:)
77
+ format(live ? MSG_LIVE : MSG, delimiter: single_quote(delimiter))
78
+ end
79
+
80
+ # `<<~RUBY` → `<<~'RUBY'`. The prefix (`<<`, `<<~`, or `<<-`) is kept; only
81
+ # the identifier gets wrapped in single quotes.
82
+ def single_quote(delimiter)
83
+ delimiter.sub(/(<<[~-]?)(\w+)\z/, "\\1'\\2'")
84
+ end
85
+
86
+ def single_quoted?(delimiter)
87
+ delimiter.include?("'")
88
+ end
89
+
90
+ def escaped_interpolation?(node)
91
+ node.loc.heredoc_body.source.match?(ESCAPED_INTERPOLATION)
92
+ end
93
+
94
+ # A live interpolation makes the heredoc a dstr whose children include a
95
+ # non-`str` node: `#{expr}` → a `begin` child, `#@ivar` → an `ivar` child,
96
+ # `#$global` → a `gvar` child. Escaped forms stay inside plain `str`
97
+ # children, so a str heredoc — or a dstr of only `str` children — is safe
98
+ # to convert. (Checking "not a str" rather than enumerating begin/ivar/gvar
99
+ # covers every interpolation node the parser can emit.)
100
+ def live_interpolation?(node)
101
+ node.dstr_type? && node.children.any? { |child| !child.str_type? }
102
+ end
103
+
104
+ # Swap the opening delimiter for its single-quoted form and drop the
105
+ # backslash from every escaped interpolation (`\#{`, `\#@`, `\#$`) in the
106
+ # body — the single-quoted delimiter already makes each one literal, and
107
+ # leaving a backslash behind would turn an escape-consumed byte into a
108
+ # literal one, changing the string.
109
+ def autocorrect(corrector, node, opening)
110
+ corrector.replace(opening, single_quote(opening.source))
111
+
112
+ body = node.loc.heredoc_body
113
+ unescaped = body.source.gsub(ESCAPED_INTERPOLATION, "#")
114
+ corrector.replace(body, unescaped)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end