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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +939 -0
- data/app/components/docs_ui/brand_mark.rb +88 -0
- data/app/components/docs_ui/callout.rb +37 -0
- data/app/components/docs_ui/code.rb +123 -0
- data/app/components/docs_ui/endpoint.rb +44 -0
- data/app/components/docs_ui/error_table.rb +72 -0
- data/app/components/docs_ui/example.rb +102 -0
- data/app/components/docs_ui/field_table.rb +46 -0
- data/app/components/docs_ui/header.rb +30 -0
- data/app/components/docs_ui/icon.rb +65 -0
- data/app/components/docs_ui/json_response.rb +46 -0
- data/app/components/docs_ui/markdown.rb +187 -0
- data/app/components/docs_ui/markdown_action.rb +45 -0
- data/app/components/docs_ui/on_this_page.rb +104 -0
- data/app/components/docs_ui/open_api_operation.rb +126 -0
- data/app/components/docs_ui/page.rb +83 -0
- data/app/components/docs_ui/page_helpers.rb +52 -0
- data/app/components/docs_ui/prop_table.rb +43 -0
- data/app/components/docs_ui/prose.rb +30 -0
- data/app/components/docs_ui/request_example.rb +85 -0
- data/app/components/docs_ui/search_box.rb +106 -0
- data/app/components/docs_ui/search_results.rb +95 -0
- data/app/components/docs_ui/section.rb +94 -0
- data/app/components/docs_ui/shell.rb +161 -0
- data/app/components/docs_ui/sidebar.rb +106 -0
- data/app/components/docs_ui/table.rb +64 -0
- data/app/components/docs_ui/theme_switcher.rb +46 -0
- data/app/components/docs_ui/topbar_links.rb +42 -0
- data/app/controllers/docs_kit/llms_controller.rb +76 -0
- data/app/controllers/docs_kit/mcp_controller.rb +60 -0
- data/app/controllers/docs_kit/search_controller.rb +72 -0
- data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
- data/config/importmap.rb +15 -0
- data/config/rubocop/docs_kit.yml +24 -0
- data/exe/docs-kit +80 -0
- data/lib/docs-kit.rb +5 -0
- data/lib/docs_kit/api_client.rb +52 -0
- data/lib/docs_kit/api_request.rb +66 -0
- data/lib/docs_kit/api_templates.rb +92 -0
- data/lib/docs_kit/configuration.rb +485 -0
- data/lib/docs_kit/controller.rb +47 -0
- data/lib/docs_kit/engine.rb +49 -0
- data/lib/docs_kit/llms_text.rb +105 -0
- data/lib/docs_kit/markdown_export/blocks.rb +160 -0
- data/lib/docs_kit/markdown_export/inline.rb +95 -0
- data/lib/docs_kit/markdown_export/table.rb +53 -0
- data/lib/docs_kit/markdown_export.rb +92 -0
- data/lib/docs_kit/mcp_server.rb +128 -0
- data/lib/docs_kit/mcp_tools.rb +118 -0
- data/lib/docs_kit/nav_item.rb +22 -0
- data/lib/docs_kit/open_api/document.rb +91 -0
- data/lib/docs_kit/open_api/operation.rb +213 -0
- data/lib/docs_kit/open_api/schema.rb +178 -0
- data/lib/docs_kit/open_api.rb +55 -0
- data/lib/docs_kit/registry.rb +152 -0
- data/lib/docs_kit/rubocop.rb +19 -0
- data/lib/docs_kit/search_hit.rb +28 -0
- data/lib/docs_kit/search_index/snippet.rb +65 -0
- data/lib/docs_kit/search_index.rb +169 -0
- data/lib/docs_kit/shortcut.rb +99 -0
- data/lib/docs_kit/templates/new_site.rb +175 -0
- data/lib/docs_kit/topbar_link.rb +39 -0
- data/lib/docs_kit/version.rb +5 -0
- data/lib/docs_kit.rb +72 -0
- data/lib/generators/docs_kit/install/USAGE +15 -0
- data/lib/generators/docs_kit/install/install_generator.rb +447 -0
- data/lib/generators/docs_kit/install/sync_report.rb +64 -0
- data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
- data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
- data/lib/generators/docs_kit/install/templates/build-css +34 -0
- data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
- data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
- data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
- data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
- data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
- data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
- data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
- data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
- data/lib/generators/docs_kit/page/USAGE +26 -0
- data/lib/generators/docs_kit/page/page_generator.rb +127 -0
- data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
- data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
- data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
- 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,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
|