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,485 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
# Per-site configuration for the shared docs chrome. Everything that differs
|
|
5
|
+
# between two otherwise-identical docs sites lives here, so the Phlex shell
|
|
6
|
+
# (DocsUI::Shell, DocsUI::Sidebar, DocsUI::ThemeSwitcher) is byte-identical across
|
|
7
|
+
# sites and only the config changes.
|
|
8
|
+
#
|
|
9
|
+
# DocsKit.configure do |c|
|
|
10
|
+
# c.brand = "phlex-reactive"
|
|
11
|
+
# c.title_suffix = "phlex-reactive"
|
|
12
|
+
# c.themes = %w[dark light synthwave ...]
|
|
13
|
+
# c.nav = -> { { "Demos" => Demo.grouped, "Docs" => Doc.grouped } }
|
|
14
|
+
# end
|
|
15
|
+
class Configuration
|
|
16
|
+
# The brand text shown in the topbar and sidebar header.
|
|
17
|
+
attr_accessor :brand
|
|
18
|
+
|
|
19
|
+
# A one-line site summary, rendered as the llms.txt blockquote
|
|
20
|
+
# (`> {tagline}`) under the H1. Defaults to nil → the blockquote line is
|
|
21
|
+
# omitted, so a site that never sets it still gets a valid llms.txt. Purely
|
|
22
|
+
# for the AI-readable index (DocsKit::LlmsText); the chrome never shows it.
|
|
23
|
+
attr_accessor :tagline
|
|
24
|
+
|
|
25
|
+
# The href the topbar brand link points at. Defaults to "/" (site root). A
|
|
26
|
+
# site whose docs live under a subpath sets its own (e.g. "/docs") so the
|
|
27
|
+
# brand link is a one-line config change, not a Shell subclass.
|
|
28
|
+
attr_accessor :brand_href
|
|
29
|
+
|
|
30
|
+
# Appended to a page's <title> (e.g. "Installation · phlex-reactive").
|
|
31
|
+
# Defaults to #brand when unset.
|
|
32
|
+
attr_writer :title_suffix
|
|
33
|
+
|
|
34
|
+
# The themes offered by the ThemeSwitcher. The first is the page default
|
|
35
|
+
# unless #default_theme is set. Must match the themes enabled in the
|
|
36
|
+
# site's Tailwind @plugin "daisyui" { themes: ... } block.
|
|
37
|
+
attr_accessor :themes
|
|
38
|
+
|
|
39
|
+
# The data-theme applied to <html> on first paint. Defaults to the first
|
|
40
|
+
# entry of #themes.
|
|
41
|
+
attr_writer :default_theme
|
|
42
|
+
|
|
43
|
+
# A callable returning the sidebar nav as an ordered Hash of
|
|
44
|
+
# { "Heading" => { "Subgroup" => [items] } }. Each item must respond to
|
|
45
|
+
# the duck type the Sidebar renders (see DocsUI::Sidebar#nav_link): #href,
|
|
46
|
+
# #label, and optional #icon. Defaults to an empty nav.
|
|
47
|
+
#
|
|
48
|
+
# Prefer #nav_registries for the common case — an explicit #nav lambda is
|
|
49
|
+
# only needed for bespoke nav (multiple registries interleaved, custom
|
|
50
|
+
# subgroups). When #nav is left at its default, the sidebar derives from
|
|
51
|
+
# #nav_registries instead.
|
|
52
|
+
attr_reader :nav
|
|
53
|
+
|
|
54
|
+
# Assigning #nav marks it explicit, so #nav_groups uses it verbatim rather
|
|
55
|
+
# than deriving from #nav_registries — tracked by a flag, not object
|
|
56
|
+
# identity, so ANY assigned lambda wins (even one that resolves to {}).
|
|
57
|
+
def nav=(value)
|
|
58
|
+
@nav_explicit = true
|
|
59
|
+
@nav = value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An ordered { "Heading" => registry_class } map. Each registry responds to
|
|
63
|
+
# .nav_items (Registry v2) → { group => [NavItem] } for its authored pages.
|
|
64
|
+
# #nav_groups derives the whole sidebar from this with zero site code, so a
|
|
65
|
+
# site never hand-writes the nav lambda. Defaults to {}. An explicit #nav
|
|
66
|
+
# lambda still wins (full backwards compatibility).
|
|
67
|
+
attr_accessor :nav_registries
|
|
68
|
+
|
|
69
|
+
# Optional callable returning a short version-badge string for the sidebar
|
|
70
|
+
# header (e.g. -> { "v#{DaisyUI::VERSION}" }). nil renders no badge.
|
|
71
|
+
attr_accessor :version_badge
|
|
72
|
+
|
|
73
|
+
# The stylesheet logical names linked in <head>, in order. Defaults to
|
|
74
|
+
# ["application"] (the Bun/Tailwind-compiled build). A site that ships extra
|
|
75
|
+
# stylesheets (e.g. a separate rouge theme) lists them here.
|
|
76
|
+
attr_accessor :stylesheets
|
|
77
|
+
|
|
78
|
+
# The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS.
|
|
79
|
+
# This is the BASE (light) theme, emitted un-scoped so it applies to every
|
|
80
|
+
# theme unless a dark override wins (see #code_theme_dark).
|
|
81
|
+
attr_accessor :code_theme
|
|
82
|
+
|
|
83
|
+
# An optional second Rouge theme (String name or Class) used for the site's
|
|
84
|
+
# DARK daisyUI themes. Default nil → single-theme behavior, fully backwards
|
|
85
|
+
# compatible. When set, DocsUI::Code additionally emits this theme's CSS scoped
|
|
86
|
+
# under [data-theme=X] .code-highlight for each shipped dark theme (see
|
|
87
|
+
# #dark_themes), so code blocks stay readable when the switcher flips to a
|
|
88
|
+
# dark theme — CSS-only, no JS, no flash.
|
|
89
|
+
attr_accessor :code_theme_dark
|
|
90
|
+
|
|
91
|
+
# The theme names treated as DARK for #code_theme_dark scoping. Defaults to
|
|
92
|
+
# the built-in daisyUI dark themes (DEFAULT_DARK_THEMES). Intersected with
|
|
93
|
+
# #themes at render time (see #dark_themes_shipped) so only shipped themes
|
|
94
|
+
# generate CSS. Override to name custom dark themes (e.g. %w[zazu-dark]).
|
|
95
|
+
attr_accessor :dark_themes
|
|
96
|
+
|
|
97
|
+
# The lucide icon name used for a nav group with no explicit icon.
|
|
98
|
+
attr_accessor :default_group_icon
|
|
99
|
+
|
|
100
|
+
# The RailsIcons library docs-kit renders its OWN chrome icons from (the
|
|
101
|
+
# sidebar carets, search glyph, theme toggle, etc.). Defaults to "lucide" to
|
|
102
|
+
# match the lucide icon names docs-kit ships. This is independent of the host
|
|
103
|
+
# app's RailsIcons.configuration.default_library — a host whose default is
|
|
104
|
+
# phosphor/heroicons can leave this at "lucide" so the chrome keeps rendering,
|
|
105
|
+
# without flipping its global default. Set to nil to defer to the host's
|
|
106
|
+
# default_library.
|
|
107
|
+
attr_accessor :icon_library
|
|
108
|
+
|
|
109
|
+
# Namespaces the sidebar's localStorage keys (collapse state) so two docs
|
|
110
|
+
# sites on the same origin don't clobber each other. Defaults to a slug of
|
|
111
|
+
# the brand.
|
|
112
|
+
attr_writer :nav_storage_key
|
|
113
|
+
|
|
114
|
+
# The default "On this page" (auto-TOC) placement, used by DocsUI::Page when a
|
|
115
|
+
# page doesn't pass its own on_page:. One of the ON_PAGE_MODES, or false to
|
|
116
|
+
# render no auto-TOC by default.
|
|
117
|
+
attr_writer :on_page_default
|
|
118
|
+
|
|
119
|
+
# Friendly-name → Rouge lexer aliases for code blocks, merged over the
|
|
120
|
+
# built-in defaults. Any language Rouge knows (~200) already works by its own
|
|
121
|
+
# name/alias; use this only to add or override (e.g. { curl: "console",
|
|
122
|
+
# dockerfile: "docker" }). Value is anything Rouge::Lexer.find accepts.
|
|
123
|
+
attr_accessor :code_lexer_aliases
|
|
124
|
+
|
|
125
|
+
# The lexer used when a requested language can't be resolved. Default
|
|
126
|
+
# "plaintext" (no highlighting, never raises).
|
|
127
|
+
attr_accessor :code_lexer_fallback
|
|
128
|
+
|
|
129
|
+
# Human labels for language tabs in DocsUI::Example, merged over the built-ins
|
|
130
|
+
# (e.g. { elixir: "Elixir", curl: "cURL" }). Unknown tokens humanize.
|
|
131
|
+
attr_accessor :code_language_labels
|
|
132
|
+
|
|
133
|
+
# Whether DocsUI::Page shows the "Markdown" masthead action — a link to the
|
|
134
|
+
# page's `.md` twin that docs-nav enhances into copy-to-clipboard. Defaults to
|
|
135
|
+
# true; set false to hide the affordance site-wide (the `.md` route still
|
|
136
|
+
# works). See DocsKit::MarkdownExport / DocsKit::Controller#render_page.
|
|
137
|
+
attr_accessor :page_markdown_action
|
|
138
|
+
|
|
139
|
+
# Whether the built-in read-only MCP endpoint (DocsKit::McpController, a
|
|
140
|
+
# POST /mcp JSON-RPC server exposing list_pages / get_page / search_docs over
|
|
141
|
+
# the same registry the docs render from) is active. Defaults to true, but the
|
|
142
|
+
# endpoint only turns on when the optional `mcp` gem is ALSO present and the
|
|
143
|
+
# host draws the route — #mcp_enabled? gates on both. Set false to keep the
|
|
144
|
+
# endpoint off even on a site that bundles the gem. See DocsKit::McpServer.
|
|
145
|
+
attr_accessor :mcp
|
|
146
|
+
|
|
147
|
+
# Whether the topbar renders the docs-search form (and the docs-nav palette
|
|
148
|
+
# markup). Defaults to true. Set false to hide search site-wide — the route
|
|
149
|
+
# can stay drawn, but no affordance points at it. Gated together with a
|
|
150
|
+
# present #search_path by #search_enabled?, which the Shell reads.
|
|
151
|
+
attr_accessor :search
|
|
152
|
+
|
|
153
|
+
# The path the topbar search form submits to (GET ?q=), and the base the
|
|
154
|
+
# palette fetches `.json` from. Defaults to "/docs/search" — the route the
|
|
155
|
+
# install generator draws. A site that mounts search elsewhere sets its own;
|
|
156
|
+
# blank it to disable the affordance without touching #search.
|
|
157
|
+
attr_accessor :search_path
|
|
158
|
+
|
|
159
|
+
# The keyboard shortcut STRINGS that open the search palette, e.g.
|
|
160
|
+
# %w[/ mod+k s]. Defaults to DEFAULT_SEARCH_SHORTCUTS (["/", "mod+k"] — the
|
|
161
|
+
# keys shipped before this was configurable, so existing sites are unchanged).
|
|
162
|
+
# "mod" is the platform modifier (⌘ on mac, Ctrl elsewhere), so one entry
|
|
163
|
+
# works on every OS. Read the parsed form via #search_shortcuts (which maps to
|
|
164
|
+
# DocsKit::Shortcut and drops anything unparseable), never @search_shortcuts.
|
|
165
|
+
attr_writer :search_shortcuts
|
|
166
|
+
|
|
167
|
+
# The API base URL prefixed onto a DocsUI::RequestExample path so copy-pasted
|
|
168
|
+
# snippets point at a real host. Defaults to a neutral example host; a site
|
|
169
|
+
# sets its own (e.g. "https://api.acme.com").
|
|
170
|
+
attr_accessor :api_base_url
|
|
171
|
+
|
|
172
|
+
# An example Authorization header line merged into every generated request
|
|
173
|
+
# snippet (e.g. "Authorization: Bearer sk_live_..."). Defaults to nil → no
|
|
174
|
+
# auth line, so a site with no auth example renders clean snippets.
|
|
175
|
+
attr_accessor :api_auth_header
|
|
176
|
+
|
|
177
|
+
# Site overrides/extensions for the DocsUI::RequestExample client tabs — an
|
|
178
|
+
# ordered { token => DocsKit::ApiClient } Hash merged OVER the four shipped
|
|
179
|
+
# defaults (curl, javascript, ruby, python). Reusing a default token replaces
|
|
180
|
+
# that client (SDK-flavored snippet); a new token appends a tab (e.g. a `cli`).
|
|
181
|
+
# Read the effective map via #api_clients (which merges), never @api_clients.
|
|
182
|
+
attr_writer :api_clients
|
|
183
|
+
|
|
184
|
+
# External links rendered in the topbar next to the theme switcher — a repo
|
|
185
|
+
# link, a chat invite, a social profile. Each entry is a Hash
|
|
186
|
+
# ({ href:, label:, icon: }) or a DocsKit::TopbarLink; #topbar_links
|
|
187
|
+
# normalizes them into TopbarLink value objects. Defaults to [] → the topbar
|
|
188
|
+
# is byte-identical to before, so a site that sets nothing is unchanged.
|
|
189
|
+
# `icon` is a shipped brand mark (:github, :discord, …) or a lucide icon
|
|
190
|
+
# name. Read the normalized list via #topbar_links, never @topbar_links.
|
|
191
|
+
attr_writer :topbar_links
|
|
192
|
+
|
|
193
|
+
# The OpenAPI spec the `operation` page helper / DocsUI::OpenApiOperation read
|
|
194
|
+
# from: a String/Pathname path (`.json` parsed as JSON, else YAML) or an
|
|
195
|
+
# already-parsed Hash. Defaults to nil → the bridge is off and a site that
|
|
196
|
+
# doesn't set it is byte-identical to before. Read the loaded model via
|
|
197
|
+
# #openapi_document (which memoizes + reloads on file change), never @openapi.
|
|
198
|
+
attr_accessor :openapi
|
|
199
|
+
|
|
200
|
+
# The sentinel "no explicit nav" lambda. #nav_groups compares against this
|
|
201
|
+
# identity to decide whether to derive the sidebar from #nav_registries.
|
|
202
|
+
DEFAULT_NAV = -> { {} }
|
|
203
|
+
|
|
204
|
+
# The search-palette shortcuts before this was configurable — "/" and the
|
|
205
|
+
# platform command chord — so a site that never sets #search_shortcuts keeps
|
|
206
|
+
# exactly the previous behavior.
|
|
207
|
+
DEFAULT_SEARCH_SHORTCUTS = ["/", "mod+k"].freeze
|
|
208
|
+
|
|
209
|
+
# The built-in daisyUI theme names that are dark. #dark_themes defaults to
|
|
210
|
+
# this; #dark_themes_shipped intersects it with the site's #themes so only
|
|
211
|
+
# shipped themes ever generate dark code CSS. A site with custom dark themes
|
|
212
|
+
# overrides #dark_themes (docs-kit can't see the compiled daisyUI CSS to
|
|
213
|
+
# detect darkness at render time, so an honest static list + override wins).
|
|
214
|
+
DEFAULT_DARK_THEMES = %w[
|
|
215
|
+
dark synthwave halloween forest black luxury dracula
|
|
216
|
+
business night coffee dim sunset abyss
|
|
217
|
+
].freeze
|
|
218
|
+
|
|
219
|
+
# Built-in friendly aliases (kept small — Rouge resolves most names itself).
|
|
220
|
+
DEFAULT_LEXER_ALIASES = { curl: "console", console: "console" }.freeze
|
|
221
|
+
|
|
222
|
+
# Built-in tab labels for the common languages that don't just capitalize.
|
|
223
|
+
DEFAULT_LANGUAGE_LABELS = {
|
|
224
|
+
javascript: "JavaScript", typescript: "TypeScript", php: "PHP",
|
|
225
|
+
curl: "cURL", json: "JSON", yaml: "YAML", html: "HTML", css: "CSS",
|
|
226
|
+
erb: "ERB", jsx: "JSX", tsx: "TSX", sql: "SQL", graphql: "GraphQL"
|
|
227
|
+
}.freeze
|
|
228
|
+
|
|
229
|
+
def initialize
|
|
230
|
+
@brand = "Docs"
|
|
231
|
+
@tagline = nil
|
|
232
|
+
@brand_href = "/"
|
|
233
|
+
@title_suffix = nil
|
|
234
|
+
@themes = %w[dark light]
|
|
235
|
+
@default_theme = nil
|
|
236
|
+
# The default nav lambda. Until a site assigns #nav (which sets
|
|
237
|
+
# @nav_explicit), #nav_groups treats nav as "unset" and derives the sidebar
|
|
238
|
+
# from #nav_registries instead; an explicit c.nav (any lambda) then wins.
|
|
239
|
+
@nav = DEFAULT_NAV
|
|
240
|
+
@nav_explicit = false
|
|
241
|
+
@nav_registries = {}
|
|
242
|
+
@version_badge = nil
|
|
243
|
+
@stylesheets = %w[application]
|
|
244
|
+
@code_theme = "Rouge::Themes::Monokai"
|
|
245
|
+
@code_theme_dark = nil
|
|
246
|
+
@dark_themes = DEFAULT_DARK_THEMES
|
|
247
|
+
@default_group_icon = "file-text"
|
|
248
|
+
@icon_library = "lucide"
|
|
249
|
+
@nav_storage_key = nil
|
|
250
|
+
@on_page_default = :panel
|
|
251
|
+
@code_lexer_aliases = {}
|
|
252
|
+
@code_lexer_fallback = "plaintext"
|
|
253
|
+
@code_language_labels = {}
|
|
254
|
+
@page_markdown_action = true
|
|
255
|
+
@mcp = true
|
|
256
|
+
@search = true
|
|
257
|
+
@search_path = "/docs/search"
|
|
258
|
+
@search_shortcuts = DEFAULT_SEARCH_SHORTCUTS
|
|
259
|
+
@api_base_url = "https://api.example.com"
|
|
260
|
+
@api_auth_header = nil
|
|
261
|
+
@api_clients = {}
|
|
262
|
+
@topbar_links = []
|
|
263
|
+
@openapi = nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The normalized topbar links (DocsKit::TopbarLink list), in declaration
|
|
267
|
+
# order. Each configured Hash/TopbarLink is coerced via TopbarLink.from, so
|
|
268
|
+
# the Shell only ever sees value objects. Blank/nil config yields [].
|
|
269
|
+
def topbar_links
|
|
270
|
+
Array(@topbar_links).map { |link| DocsKit::TopbarLink.from(link) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The loaded DocsKit::OpenApi::Document for #openapi. Memoized; when #openapi
|
|
274
|
+
# is a file path, the memo is invalidated on an mtime change so editing the
|
|
275
|
+
# spec in development is picked up without a server restart. Raises a
|
|
276
|
+
# DocsKit::Error naming the knob when read while #openapi is unset — a missing
|
|
277
|
+
# spec has nothing useful to degrade to.
|
|
278
|
+
def openapi_document
|
|
279
|
+
raise DocsKit::Error, "no OpenAPI spec configured — set c.openapi to a path or Hash" if @openapi.nil?
|
|
280
|
+
|
|
281
|
+
mtime = openapi_source_mtime
|
|
282
|
+
return @openapi_document if defined?(@openapi_document) && @openapi_document_mtime == mtime
|
|
283
|
+
|
|
284
|
+
@openapi_document_mtime = mtime
|
|
285
|
+
@openapi_document = DocsKit::OpenApi.load(@openapi)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The effective client map for DocsUI::RequestExample: the four shipped
|
|
289
|
+
# defaults with site overrides/extensions merged over them. Hash#merge keeps
|
|
290
|
+
# a reused token in its original position and appends new tokens in
|
|
291
|
+
# declaration order, so tab order is stable and predictable.
|
|
292
|
+
def api_clients
|
|
293
|
+
DocsKit::ApiClient::DEFAULTS.merge(@api_clients || {})
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# The effective alias map (built-ins + site overrides), symbol-keyed.
|
|
297
|
+
def lexer_aliases
|
|
298
|
+
DEFAULT_LEXER_ALIASES.merge((@code_lexer_aliases || {}).transform_keys(&:to_sym))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# The effective label map (built-ins + site overrides), symbol-keyed.
|
|
302
|
+
def language_labels
|
|
303
|
+
DEFAULT_LANGUAGE_LABELS.merge((@code_language_labels || {}).transform_keys(&:to_sym))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The auto-TOC placements, all driven by the same docs-nav Stimulus
|
|
307
|
+
# controller (it reads the page's headings from the DOM):
|
|
308
|
+
# :panel — a sticky card in the top-right of the content column
|
|
309
|
+
# :toggle — a sticky floating button (top-right) opening a dropdown
|
|
310
|
+
# :sidebar — nested under the active nav item in the left sidebar
|
|
311
|
+
ON_PAGE_MODES = %i[panel toggle sidebar].freeze
|
|
312
|
+
|
|
313
|
+
# The resolved default placement (a mode symbol or false). A bare `true`
|
|
314
|
+
# default means :panel (the canonical default), never a self-reference.
|
|
315
|
+
def on_page_default
|
|
316
|
+
raw = @on_page_default
|
|
317
|
+
raw = :panel if raw == true
|
|
318
|
+
coerce_on_page_mode(raw)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Coerce a per-page on_page: value to a valid mode symbol or false. `true`
|
|
322
|
+
# means "use the configured default".
|
|
323
|
+
def normalize_on_page(value)
|
|
324
|
+
return on_page_default if value == true
|
|
325
|
+
|
|
326
|
+
coerce_on_page_mode(value)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
# True when the optional `mcp` gem can be loaded. Memoized across both
|
|
332
|
+
# outcomes so a site without the gem doesn't pay a failed require per request.
|
|
333
|
+
# We attempt the require lazily (rather than only checking defined?(MCP)) so a
|
|
334
|
+
# bundled-but-not-yet-required gem still counts as present — the same
|
|
335
|
+
# degrade-gracefully-on-a-missing-optional-gem posture as DocsUI::Icon's
|
|
336
|
+
# rails_icons guard.
|
|
337
|
+
def mcp_gem_present?
|
|
338
|
+
return @mcp_gem_present if defined?(@mcp_gem_present)
|
|
339
|
+
|
|
340
|
+
@mcp_gem_present =
|
|
341
|
+
begin
|
|
342
|
+
require "mcp"
|
|
343
|
+
defined?(::MCP::Server) ? true : false
|
|
344
|
+
rescue LoadError
|
|
345
|
+
false
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# The mtime of the #openapi source when it's a readable file path, else nil
|
|
350
|
+
# (a Hash spec, or a path that isn't a file). Drives #openapi_document's
|
|
351
|
+
# reload-on-change memoization; a Hash spec loads once and never invalidates.
|
|
352
|
+
def openapi_source_mtime
|
|
353
|
+
return if @openapi.is_a?(Hash)
|
|
354
|
+
|
|
355
|
+
path = Pathname.new(@openapi)
|
|
356
|
+
path.file? ? path.mtime : nil
|
|
357
|
+
rescue StandardError
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# { heading => registry.nav_items }, dropping headings with no authored
|
|
362
|
+
# pages so the sidebar never shows an empty group.
|
|
363
|
+
def nav_groups_from_registries
|
|
364
|
+
@nav_registries.each_with_object({}) do |(heading, registry), acc|
|
|
365
|
+
items = registry.nav_items
|
|
366
|
+
acc[heading] = items unless items.empty?
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def coerce_on_page_mode(value)
|
|
371
|
+
case value
|
|
372
|
+
when false, nil then false
|
|
373
|
+
when *ON_PAGE_MODES then value.to_sym
|
|
374
|
+
else
|
|
375
|
+
raise ArgumentError,
|
|
376
|
+
"on_page must be one of #{ON_PAGE_MODES.inspect}, true, or false (got #{value.inspect})"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
public
|
|
381
|
+
|
|
382
|
+
def nav_storage_key
|
|
383
|
+
@nav_storage_key || @brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def title_suffix
|
|
387
|
+
@title_suffix || @brand
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Whether the built-in MCP endpoint is active: the #mcp toggle is on AND the
|
|
391
|
+
# optional `mcp` gem is loadable — the same "toggle AND capability" shape as
|
|
392
|
+
# #search_enabled?. Read by DocsKit::LlmsText (to advertise /mcp in llms.txt)
|
|
393
|
+
# and DocsKit::McpController (to 404 when off), so a site without the gem, or
|
|
394
|
+
# one that set c.mcp = false, is byte-identical to before this feature.
|
|
395
|
+
def mcp_enabled?
|
|
396
|
+
!!@mcp && mcp_gem_present?
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Whether the Shell renders the search affordance: search is on AND a path is
|
|
400
|
+
# set to submit to. A site with @search_path blanked (or nil) gets no form
|
|
401
|
+
# even if @search is true — there'd be nothing to submit to.
|
|
402
|
+
def search_enabled?
|
|
403
|
+
!!@search && !@search_path.to_s.empty?
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# The parsed search-palette shortcuts (DocsKit::Shortcut list), with anything
|
|
407
|
+
# unparseable dropped. The topbar renders one <kbd> per entry and docs-nav
|
|
408
|
+
# binds each; an empty list means no keyboard shortcut (the form still works).
|
|
409
|
+
def search_shortcuts
|
|
410
|
+
DocsKit::Shortcut.parse_list(@search_shortcuts)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def default_theme
|
|
414
|
+
@default_theme || Array(@themes).first
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# The resolved nav Hash for this request. Always returns a Hash.
|
|
418
|
+
#
|
|
419
|
+
# An explicit #nav lambda wins. Otherwise the sidebar derives from
|
|
420
|
+
# #nav_registries: each heading maps to its registry's .nav_items, and a
|
|
421
|
+
# heading whose pages are all unauthored (empty nav_items) is dropped so no
|
|
422
|
+
# empty group renders.
|
|
423
|
+
def nav_groups
|
|
424
|
+
return nav_groups_from_registries unless @nav_explicit
|
|
425
|
+
|
|
426
|
+
result = @nav.respond_to?(:call) ? @nav.call : @nav
|
|
427
|
+
result || {}
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# The resolved version badge string, or nil.
|
|
431
|
+
# The rendered version badge. A callable is invoked; a plain String (or any
|
|
432
|
+
# non-nil value) is coerced to its string form — so `c.version_badge = "v1.2"`
|
|
433
|
+
# renders, not only a lambda.
|
|
434
|
+
def version_badge_text
|
|
435
|
+
return if @version_badge.nil?
|
|
436
|
+
return @version_badge.call if @version_badge.respond_to?(:call)
|
|
437
|
+
|
|
438
|
+
@version_badge.to_s
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# The default Rouge theme both #code_theme_class and #code_theme_dark_class
|
|
442
|
+
# fall back to when a configured theme name can't be resolved — so a typo'd
|
|
443
|
+
# theme name degrades gracefully instead of raising on every code block.
|
|
444
|
+
DEFAULT_CODE_THEME = "Rouge::Themes::Monokai"
|
|
445
|
+
|
|
446
|
+
# The Rouge theme class resolved from #code_theme (String or class). A String
|
|
447
|
+
# name that doesn't resolve degrades to the default theme rather than raising
|
|
448
|
+
# NameError on every DocsUI::Code render.
|
|
449
|
+
def code_theme_class
|
|
450
|
+
return @code_theme if @code_theme.is_a?(Class)
|
|
451
|
+
|
|
452
|
+
resolve_theme(@code_theme) || Object.const_get(DEFAULT_CODE_THEME)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# The dark Rouge theme class resolved from #code_theme_dark (String or
|
|
456
|
+
# class), or nil when unset — mirrors #code_theme_class. DocsUI::Code emits
|
|
457
|
+
# dark code CSS only when this is non-nil, so an unresolvable name degrades to
|
|
458
|
+
# nil (no dark restyle) rather than raising.
|
|
459
|
+
def code_theme_dark_class
|
|
460
|
+
return if @code_theme_dark.nil?
|
|
461
|
+
return @code_theme_dark if @code_theme_dark.is_a?(Class)
|
|
462
|
+
|
|
463
|
+
resolve_theme(@code_theme_dark)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# The dark themes the site actually ships: #dark_themes intersected with
|
|
467
|
+
# #themes, in #themes declaration order. DocsUI::Code scopes the dark theme's
|
|
468
|
+
# CSS under [data-theme=X] for each of these, so a dark theme that isn't in
|
|
469
|
+
# the Tailwind build never emits dead CSS.
|
|
470
|
+
def dark_themes_shipped
|
|
471
|
+
Array(@themes) & Array(@dark_themes)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
private
|
|
475
|
+
|
|
476
|
+
# Resolve a Rouge theme constant from its String name, returning nil (not
|
|
477
|
+
# raising) when the name doesn't resolve — a typo'd or unloaded theme must
|
|
478
|
+
# not crash every code block on the page.
|
|
479
|
+
def resolve_theme(name)
|
|
480
|
+
Object.const_get(name.to_s)
|
|
481
|
+
rescue NameError
|
|
482
|
+
nil
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
# Controller glue for a docs site. Include in ApplicationController to get the
|
|
5
|
+
# one shared render helper.
|
|
6
|
+
#
|
|
7
|
+
# class ApplicationController < ActionController::Base
|
|
8
|
+
# include DocsKit::Controller
|
|
9
|
+
# def show = render_page(Views::Landings::Show.new)
|
|
10
|
+
# end
|
|
11
|
+
module Controller
|
|
12
|
+
# Render a Phlex page that is itself a full HTML document (it composes
|
|
13
|
+
# Docs::Shell, which emits <html>/<head>/<body>). `layout: false` prevents the
|
|
14
|
+
# Rails ERB application layout from double-nesting <html>. phlex-rails still
|
|
15
|
+
# renders through a real view context, so CSRF, dom_id, url helpers, and the
|
|
16
|
+
# phlex-reactive token signer all work inside components.
|
|
17
|
+
#
|
|
18
|
+
# A `.md`/`.text` request instead returns the page's Markdown twin, derived
|
|
19
|
+
# from the SAME render (DocsKit::MarkdownExport walks the rendered HTML). So
|
|
20
|
+
# `GET /docs/x.md` is faithful GFM of exactly what `/docs/x` shows — the
|
|
21
|
+
# author writes nothing extra, and the two never drift.
|
|
22
|
+
def render_page(view)
|
|
23
|
+
return render_markdown(view) if markdown_request?
|
|
24
|
+
|
|
25
|
+
render view, layout: false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# True for a `.md` or `.text` request. `.text` is accepted as an alias so a
|
|
31
|
+
# host whose routes only allow the built-in `:text` format still gets the
|
|
32
|
+
# twin.
|
|
33
|
+
def markdown_request?
|
|
34
|
+
request.format.md? || request.format.text?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The Markdown twin as text/markdown. Rendered through the controller's view
|
|
38
|
+
# context (so url helpers/CSRF resolve) and with the request base URL so
|
|
39
|
+
# relative links in the export are absolutized to portable URLs.
|
|
40
|
+
def render_markdown(view)
|
|
41
|
+
markdown = DocsKit::MarkdownExport.new(
|
|
42
|
+
view, view_context:, base_url: request.base_url
|
|
43
|
+
).to_md
|
|
44
|
+
render plain: markdown, content_type: "text/markdown"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module DocsKit
|
|
6
|
+
# An asset/glue engine (no isolate_namespace, no routes/models). It makes the
|
|
7
|
+
# gem's Phlex components autoloadable in the host app and exposes the controller
|
|
8
|
+
# helper, so a docs site only adds the gem + its DocsKit.configure block.
|
|
9
|
+
class Engine < ::Rails::Engine
|
|
10
|
+
# The gem's OWN zeitwerk loader (in lib/docs_kit.rb) is the single owner of
|
|
11
|
+
# the Docs::* components under app/components/docs. Rails would otherwise also
|
|
12
|
+
# add an engine's app/* dirs to the host autoload paths and double-manage
|
|
13
|
+
# those constants, so opt this engine's app/ out of Rails autoloading.
|
|
14
|
+
config.autoload_paths = []
|
|
15
|
+
config.eager_load_paths = []
|
|
16
|
+
config.paths["app"].skip_eager_load!
|
|
17
|
+
|
|
18
|
+
JAVASCRIPT_PATH = root.join("app/javascript")
|
|
19
|
+
|
|
20
|
+
initializer "docs_kit.controller_helper" do
|
|
21
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
22
|
+
include DocsKit::Controller
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register the :md format (text/markdown) so a `.md` request routes and
|
|
27
|
+
# `request.format.md?` is true — the trigger for the page's Markdown twin
|
|
28
|
+
# (DocsKit::Controller#render_page). Guarded so re-registration (a host that
|
|
29
|
+
# already declared :md) is a no-op rather than a duplicate-type error.
|
|
30
|
+
initializer "docs_kit.mime_types" do
|
|
31
|
+
Mime::Type.register("text/markdown", :md) unless Mime::Type.lookup_by_extension(:md)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Serve the bundled Stimulus controller (docs_nav) as an asset.
|
|
35
|
+
initializer "docs_kit.assets" do |app|
|
|
36
|
+
app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Auto-pin the controller for importmap-rails consumers, so a host app gets
|
|
40
|
+
# `docs_kit/controllers/docs_nav_controller` with no manual pin.
|
|
41
|
+
initializer "docs_kit.importmap", before: "importmap" do |app|
|
|
42
|
+
next unless app.config.respond_to?(:importmap)
|
|
43
|
+
|
|
44
|
+
importmap = app.config.importmap
|
|
45
|
+
importmap.paths << root.join("config/importmap.rb") if importmap.respond_to?(:paths)
|
|
46
|
+
importmap.cache_sweepers << JAVASCRIPT_PATH if importmap.respond_to?(:cache_sweepers)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
# Builds the two AI-readable artifacts a docs-kit site serves, straight from
|
|
5
|
+
# the registry — zero authoring:
|
|
6
|
+
#
|
|
7
|
+
# /llms.txt — the llmstxt.org index: H1 brand, `> tagline` blockquote,
|
|
8
|
+
# one `## {group}` section per nav group, and a
|
|
9
|
+
# `- [title](abs .md url)` line per authored page.
|
|
10
|
+
# /llms-full.txt — every page's Markdown twin concatenated, `# {title}` +
|
|
11
|
+
# body, separated by `---`.
|
|
12
|
+
#
|
|
13
|
+
# It's a pure text builder: given a DocsKit::Configuration and (for the full
|
|
14
|
+
# form) already-rendered `[title, markdown]` pairs, it produces strings with no
|
|
15
|
+
# Rails. The controller owns the Rails view context — it renders each page to
|
|
16
|
+
# Markdown (DocsKit::MarkdownExport) and hands the pairs to .full — so all the
|
|
17
|
+
# shaping is unit-testable without booting Rails.
|
|
18
|
+
#
|
|
19
|
+
# The enumeration source is DocsKit::Registry v2: each registry in
|
|
20
|
+
# `config.nav_registries` responds to #nav_items ({ group => [NavItem] },
|
|
21
|
+
# authored pages only) for the index and #all (entries with #view_class) for
|
|
22
|
+
# the authored page list. An unwritten page (no resolvable view_class) is
|
|
23
|
+
# excluded from both, so neither artifact ever links or concatenates a page
|
|
24
|
+
# that doesn't exist yet.
|
|
25
|
+
module LlmsText
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# The llms.txt index string. base_url absolutizes each page's `.md` href so
|
|
29
|
+
# agent tooling fetches a portable URL; omit it for relative links.
|
|
30
|
+
#
|
|
31
|
+
# Blocks (H1, the tagline blockquote, and one per section) are separated by a
|
|
32
|
+
# blank line; within a section the `## heading` and its `- [..]` bullets are a
|
|
33
|
+
# single tight list (no blank lines between bullets), per the llmstxt.org
|
|
34
|
+
# convention.
|
|
35
|
+
def index(config, base_url: nil)
|
|
36
|
+
blocks = ["# #{config.brand}"]
|
|
37
|
+
tagline = config.tagline
|
|
38
|
+
blocks << "> #{tagline}" if tagline && !tagline.to_s.empty?
|
|
39
|
+
blocks.concat(section_blocks(config, base_url))
|
|
40
|
+
|
|
41
|
+
# Advertise the built-in MCP endpoint last, so an agent that reads llms.txt
|
|
42
|
+
# discovers it can also connect over the protocol (native tools vs fetching
|
|
43
|
+
# text). Only when the endpoint is actually live (gem present + c.mcp on).
|
|
44
|
+
blocks << mcp_block(base_url) if config.mcp_enabled?
|
|
45
|
+
|
|
46
|
+
blocks.join("\n\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One `## {group}` block per nav group, each a tight bullet list of its
|
|
50
|
+
# authored pages' `.md` links, in registry order.
|
|
51
|
+
def section_blocks(config, base_url)
|
|
52
|
+
groups(config).map do |group, links|
|
|
53
|
+
["## #{group}", *links.map { |link| link_line(link, base_url) }].join("\n")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The authored pages across every registry, in config/registry order — each
|
|
58
|
+
# responds to #title / #href / #view_class. The controller renders these to
|
|
59
|
+
# Markdown for .full.
|
|
60
|
+
def pages(config)
|
|
61
|
+
config.nav_registries.values.flat_map { |registry| registry.all.select(&:view_class) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The llms-full.txt body: each [title, markdown] pair as `# {title}` + body,
|
|
65
|
+
# separated by a `---` rule. Empty pairs → "".
|
|
66
|
+
def full(_config, title_markdown_pairs)
|
|
67
|
+
title_markdown_pairs.map { |title, markdown| "# #{title}\n\n#{markdown}" }.join("\n\n---\n\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# { group => [links] } across every registry's #nav_items, in config order.
|
|
71
|
+
# A registry with no authored pages contributes nothing, so no empty section
|
|
72
|
+
# is ever emitted.
|
|
73
|
+
def groups(config)
|
|
74
|
+
config.nav_registries.values.each_with_object({}) do |registry, acc|
|
|
75
|
+
registry.nav_items.each { |group, links| (acc[group] ||= []).concat(links) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The `## MCP` section pointing an agent at the read-only MCP endpoint. The
|
|
80
|
+
# `/mcp` URL is absolutized against base_url when available (agents connect to
|
|
81
|
+
# a portable URL); relative otherwise.
|
|
82
|
+
def mcp_block(base_url)
|
|
83
|
+
url = base_url ? "#{base_url.chomp('/')}/mcp" : "/mcp"
|
|
84
|
+
"## MCP\n" \
|
|
85
|
+
"This documentation is also available over the Model Context Protocol " \
|
|
86
|
+
"(search, page retrieval) at #{url} — add it to an MCP client " \
|
|
87
|
+
"(Claude Code, Claude.ai, Cursor) to query these docs as tools."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `- [label](absolute .md url)`. The `.md` suffix targets the page's Markdown
|
|
91
|
+
# twin (DocsKit::Controller#render_page).
|
|
92
|
+
def link_line(link, base_url)
|
|
93
|
+
"- [#{link.label}](#{md_url(link.href, base_url)})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# href + ".md", absolutized against base_url when given. base_url has no
|
|
97
|
+
# trailing slash concerns here (hrefs are root-relative like "/docs/x").
|
|
98
|
+
def md_url(href, base_url)
|
|
99
|
+
path = "#{href}.md"
|
|
100
|
+
return path unless base_url
|
|
101
|
+
|
|
102
|
+
"#{base_url.chomp('/')}#{path}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|