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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
module OpenApi
|
|
5
|
+
# A parsed OpenAPI 3.x document. Indexes every path/verb operation and offers
|
|
6
|
+
# two lookups — by operationId, or by (method, path) for specs that omit ids —
|
|
7
|
+
# plus the shared local-$ref resolver every Operation/Schema reads through.
|
|
8
|
+
#
|
|
9
|
+
# Keys stay Strings internally (that's how both YAML and JSON parse), so the
|
|
10
|
+
# model never guesses at symbol/string key shape.
|
|
11
|
+
class Document
|
|
12
|
+
# The HTTP verbs an OpenAPI path item may carry, lower-cased as they appear
|
|
13
|
+
# in the spec.
|
|
14
|
+
HTTP_METHODS = %w[get put post delete options head patch trace].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(raw)
|
|
17
|
+
@raw = raw
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Look up an operation. Two forms:
|
|
21
|
+
# operation("createInvoice") — by operationId
|
|
22
|
+
# operation(:post, "/v1/invoices") — by HTTP method + path
|
|
23
|
+
# Raises OperationNotFound (naming the available ids) when nothing matches.
|
|
24
|
+
def operation(id_or_method, path = nil)
|
|
25
|
+
found = path.nil? ? by_operation_id(id_or_method) : by_method_and_path(id_or_method, path)
|
|
26
|
+
found || raise(OperationNotFound, not_found_message(id_or_method, path))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Every operation across every path, in document order.
|
|
30
|
+
def operations
|
|
31
|
+
@operations ||= build_operations
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Resolve a local $ref ("#/components/schemas/Foo") to the referenced node.
|
|
35
|
+
# An external/remote ref (not starting "#/") raises UnsupportedRef. Used by
|
|
36
|
+
# Operation and Schema so ref handling lives in exactly one place.
|
|
37
|
+
def resolve_ref(ref)
|
|
38
|
+
raise UnsupportedRef, "external/remote $ref is not supported: #{ref}" unless ref.start_with?("#/")
|
|
39
|
+
|
|
40
|
+
ref.delete_prefix("#/").split("/").reduce(@raw) do |node, segment|
|
|
41
|
+
# JSON Pointer escaping: ~1 → "/", ~0 → "~".
|
|
42
|
+
key = segment.gsub("~1", "/").gsub("~0", "~")
|
|
43
|
+
node.is_a?(Hash) ? node[key] : nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Follow a node's $ref (if any) one hop to the referenced node; a node
|
|
48
|
+
# without a $ref is returned unchanged.
|
|
49
|
+
def deref(node)
|
|
50
|
+
node.is_a?(Hash) && node.key?("$ref") ? resolve_ref(node["$ref"]) : node
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_operations
|
|
56
|
+
paths.flat_map do |path, item|
|
|
57
|
+
next [] unless item.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
item.filter_map do |verb, op|
|
|
60
|
+
next unless HTTP_METHODS.include?(verb.to_s.downcase)
|
|
61
|
+
next unless op.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
Operation.new(self, method: verb, path: path, raw: op)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def paths
|
|
69
|
+
@raw["paths"] || {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def by_operation_id(id)
|
|
73
|
+
operations.find { |op| op.operation_id == id.to_s }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def by_method_and_path(method, path)
|
|
77
|
+
verb = method.to_s.downcase
|
|
78
|
+
operations.find { |op| op.http_method.downcase == verb && op.path == path }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def not_found_message(id_or_method, path)
|
|
82
|
+
if path
|
|
83
|
+
"no operation #{id_or_method.to_s.upcase} #{path} in the OpenAPI spec"
|
|
84
|
+
else
|
|
85
|
+
ids = operations.filter_map(&:operation_id)
|
|
86
|
+
"unknown operationId #{id_or_method.inspect}; available: #{ids.join(', ')}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
module OpenApi
|
|
5
|
+
# One OpenAPI operation (a path + verb), exposing exactly the shapes the kit's
|
|
6
|
+
# render targets consume:
|
|
7
|
+
# #parameter_rows / #body_rows → DocsUI::FieldTable
|
|
8
|
+
# #error_rows → DocsUI::ErrorTable
|
|
9
|
+
# #example_body / #success_example → DocsUI::JsonResponse / RequestExample body
|
|
10
|
+
# #example_path / #example_query → the RequestExample snippet URL
|
|
11
|
+
# #code_samples → DocsUI::Example tabs (x-codeSamples)
|
|
12
|
+
#
|
|
13
|
+
# It never renders — it's the bridge value object DocsUI::OpenApiOperation
|
|
14
|
+
# reads. All schema traversal delegates to DocsKit::OpenApi::Schema.
|
|
15
|
+
class Operation
|
|
16
|
+
# Both spellings of the code-samples vendor extension seen in the wild
|
|
17
|
+
# (Redoc uses x-codeSamples; older tooling x-code-samples).
|
|
18
|
+
CODE_SAMPLE_KEYS = %w[x-codeSamples x-code-samples].freeze
|
|
19
|
+
|
|
20
|
+
# The media type the bridge reads bodies/examples from.
|
|
21
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
22
|
+
|
|
23
|
+
attr_reader :path
|
|
24
|
+
|
|
25
|
+
def initialize(document, method:, path:, raw:)
|
|
26
|
+
@document = document
|
|
27
|
+
@method = method
|
|
28
|
+
@path = path
|
|
29
|
+
@raw = raw
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def http_method = @method.to_s.upcase
|
|
33
|
+
def operation_id = @raw["operationId"]
|
|
34
|
+
def summary = @raw["summary"]
|
|
35
|
+
def description = @raw["description"]
|
|
36
|
+
def deprecated? = @raw["deprecated"] == true
|
|
37
|
+
|
|
38
|
+
# The section title: the summary, else the operationId, else the verb+path.
|
|
39
|
+
def title
|
|
40
|
+
summary || operation_id || "#{http_method} #{path}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# FieldTable rows for the operation's query/path/header parameters.
|
|
44
|
+
def parameter_rows
|
|
45
|
+
parameters.map do |param|
|
|
46
|
+
{
|
|
47
|
+
name: param["name"],
|
|
48
|
+
type: Schema.new(@document, param["schema"]).type_label,
|
|
49
|
+
required: param["required"] == true,
|
|
50
|
+
description: description_cell(param["description"])
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# FieldTable rows for the request-body schema (flattened, nested-dotted).
|
|
56
|
+
def body_rows
|
|
57
|
+
schema = request_body_schema
|
|
58
|
+
return [] unless schema
|
|
59
|
+
|
|
60
|
+
Schema.new(@document, schema).rows
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ErrorTable rows for the 4xx/5xx responses: status + scenario (the response
|
|
64
|
+
# description) + an optional error type read from the response example.
|
|
65
|
+
def error_rows
|
|
66
|
+
error_responses.map do |status, response|
|
|
67
|
+
{
|
|
68
|
+
status: status,
|
|
69
|
+
scenario: response_description(response),
|
|
70
|
+
type: error_type_for(response)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A synthesized (or explicit) request body example Hash, or nil when the
|
|
76
|
+
# operation has no request body.
|
|
77
|
+
def example_body
|
|
78
|
+
schema = request_body_schema
|
|
79
|
+
return unless schema
|
|
80
|
+
|
|
81
|
+
media = request_body_media
|
|
82
|
+
return media["example"] if media&.key?("example")
|
|
83
|
+
|
|
84
|
+
first_examples_value(media) || Schema.new(@document, schema).example_value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The path with each path-parameter placeholder replaced by its example
|
|
88
|
+
# (copy-pasteable), leaving {placeholders} without an example untouched.
|
|
89
|
+
def example_path
|
|
90
|
+
path.gsub(/\{(\w+)\}/) do
|
|
91
|
+
name = Regexp.last_match(1)
|
|
92
|
+
param = path_parameters.find { |p| p["name"] == name }
|
|
93
|
+
example = param && param["example"]
|
|
94
|
+
example.nil? ? "{#{name}}" : example.to_s
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# { name => value } for query params that carry an explicit example (never
|
|
99
|
+
# invent a value for a required-but-example-less param — it stays doc-only).
|
|
100
|
+
def example_query
|
|
101
|
+
query_parameters.each_with_object({}) do |param, acc|
|
|
102
|
+
acc[param["name"]] = param["example"] if param.key?("example")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The first 2xx response's example body (explicit or synthesized), or nil.
|
|
107
|
+
def success_example
|
|
108
|
+
_, response = success_response
|
|
109
|
+
return unless response
|
|
110
|
+
|
|
111
|
+
media = json_media(response)
|
|
112
|
+
return unless media
|
|
113
|
+
|
|
114
|
+
return media["example"] if media.key?("example")
|
|
115
|
+
|
|
116
|
+
first_examples_value(media) || example_from_schema(media["schema"])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# x-codeSamples entries as { lang:, label:, source: } (either spelling).
|
|
120
|
+
def code_samples
|
|
121
|
+
raw_samples.map do |sample|
|
|
122
|
+
{
|
|
123
|
+
lang: sample["lang"],
|
|
124
|
+
label: sample["label"],
|
|
125
|
+
source: sample["source"].to_s
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def parameters
|
|
133
|
+
Array(@raw["parameters"]).map { |p| @document.deref(p) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def path_parameters = parameters.select { |p| p["in"] == "path" }
|
|
137
|
+
def query_parameters = parameters.select { |p| p["in"] == "query" }
|
|
138
|
+
|
|
139
|
+
def request_body
|
|
140
|
+
@document.deref(@raw["requestBody"])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def request_body_media
|
|
144
|
+
body = request_body
|
|
145
|
+
return unless body.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
(body["content"] || {})[JSON_MEDIA_TYPE]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def request_body_schema
|
|
151
|
+
media = request_body_media
|
|
152
|
+
media && media["schema"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def responses = @raw["responses"] || {}
|
|
156
|
+
|
|
157
|
+
def error_responses
|
|
158
|
+
responses.select { |status, _| status.to_s =~ /\A[45]/ }
|
|
159
|
+
.transform_values { |r| @document.deref(r) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def success_response
|
|
163
|
+
responses.map { |status, r| [status, @document.deref(r)] }
|
|
164
|
+
.find { |status, _| status.to_s.start_with?("2") }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def json_media(response)
|
|
168
|
+
return unless response.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
(response["content"] || {})[JSON_MEDIA_TYPE]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def response_description(response)
|
|
174
|
+
desc = response["description"]
|
|
175
|
+
desc.nil? || desc.to_s.strip.empty? ? http_method : desc.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# An error type is not a first-class OpenAPI field. When a response example
|
|
179
|
+
# carries a top-level "type" string, surface it; otherwise nil.
|
|
180
|
+
def error_type_for(response)
|
|
181
|
+
media = json_media(response)
|
|
182
|
+
return unless media
|
|
183
|
+
|
|
184
|
+
example = media["example"] || first_examples_value(media)
|
|
185
|
+
example.is_a?(Hash) ? example["type"] : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The value of the first entry in an `examples` (plural) map, or nil.
|
|
189
|
+
def first_examples_value(media)
|
|
190
|
+
return unless media.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
examples = media["examples"]
|
|
193
|
+
return unless examples.is_a?(Hash) && !examples.empty?
|
|
194
|
+
|
|
195
|
+
entry = @document.deref(examples.values.first)
|
|
196
|
+
entry.is_a?(Hash) ? entry["value"] : nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def example_from_schema(schema)
|
|
200
|
+
schema && Schema.new(@document, schema).example_value
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def description_cell(text)
|
|
204
|
+
text && !text.to_s.strip.empty? ? [:md, text.to_s] : nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def raw_samples
|
|
208
|
+
key = CODE_SAMPLE_KEYS.find { |k| @raw.key?(k) }
|
|
209
|
+
key ? Array(@raw[key]) : []
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
module OpenApi
|
|
5
|
+
# A thin wrapper over one OpenAPI schema node that answers the three questions
|
|
6
|
+
# the render targets ask: what's its display type label, what FieldTable rows
|
|
7
|
+
# do its properties flatten to, and what example value does it synthesize.
|
|
8
|
+
#
|
|
9
|
+
# All traversal is cycle-safe (a $ref that loops back stops descending) and
|
|
10
|
+
# depth-capped, because a real spec can be deeply nested or self-referential
|
|
11
|
+
# (Invoice.parent → Invoice).
|
|
12
|
+
class Schema
|
|
13
|
+
# How deep object/property flattening and example synthesis descend before
|
|
14
|
+
# stopping — guards both accidental depth and $ref cycles.
|
|
15
|
+
MAX_DEPTH = 6
|
|
16
|
+
|
|
17
|
+
def initialize(document, node)
|
|
18
|
+
@document = document
|
|
19
|
+
@node = node || {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A human display type for a FieldTable "Type" cell:
|
|
23
|
+
# "string", "integer", "array of string", "usd | eur | gbp" (enum),
|
|
24
|
+
# "one of: A | B" (oneOf/anyOf), "object".
|
|
25
|
+
def type_label
|
|
26
|
+
node = merged
|
|
27
|
+
return enum_label(node["enum"]) if node["enum"]
|
|
28
|
+
return one_of_label(node) if node["oneOf"] || node["anyOf"]
|
|
29
|
+
return "array of #{Schema.new(@document, deref(node['items'])).type_label}" if array?(node)
|
|
30
|
+
|
|
31
|
+
node["type"] || "object"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The recursion state threaded through property flattening: the dotted-name
|
|
35
|
+
# prefix, the current depth, and the $ref chain seen so far (for cycles).
|
|
36
|
+
Cursor = Data.define(:prefix, :depth, :seen) do
|
|
37
|
+
def self.root = new(prefix: "", depth: 0, seen: [])
|
|
38
|
+
def descend(name, ref) = self.class.new(prefix: name, depth: depth + 1, seen: seen + [ref].compact)
|
|
39
|
+
def dotted(name) = prefix.empty? ? name : "#{prefix}.#{name}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Flatten this schema's properties into FieldTable-ready rows. Nested object
|
|
43
|
+
# properties dot their names (customer.id); required is read from the
|
|
44
|
+
# object's `required` list. The Cursor threads prefix/depth/seen recursion.
|
|
45
|
+
def rows(cursor: Cursor.root)
|
|
46
|
+
node = merged
|
|
47
|
+
return [] if cursor.depth >= MAX_DEPTH
|
|
48
|
+
return [] unless node["properties"].is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
required = Array(node["required"])
|
|
51
|
+
node["properties"].flat_map do |name, prop_node|
|
|
52
|
+
property_rows(prop_node, name: name, required: required.include?(name), cursor: cursor)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Synthesize an example value for this schema: the node's own `example`,
|
|
57
|
+
# else `default`, else the first enum value, else a per-type placeholder
|
|
58
|
+
# (recursing into object properties / array items). Cycle-safe via `seen`.
|
|
59
|
+
def example_value(depth: 0, seen: [])
|
|
60
|
+
node = merged
|
|
61
|
+
return node["example"] if node.key?("example")
|
|
62
|
+
return node["default"] if node.key?("default")
|
|
63
|
+
return node["enum"].first if node["enum"].is_a?(Array) && !node["enum"].empty?
|
|
64
|
+
return [] if depth >= MAX_DEPTH
|
|
65
|
+
|
|
66
|
+
synthesize(node, depth, seen)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
attr_reader :document
|
|
72
|
+
|
|
73
|
+
# The node with any single $ref followed and allOf branches shallow-merged,
|
|
74
|
+
# so callers see one flat schema. oneOf/anyOf are left for #type_label.
|
|
75
|
+
def merged
|
|
76
|
+
node = deref(@node)
|
|
77
|
+
return {} unless node.is_a?(Hash)
|
|
78
|
+
return merge_all_of(node) if node["allOf"].is_a?(Array)
|
|
79
|
+
|
|
80
|
+
node
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def merge_all_of(node)
|
|
84
|
+
node["allOf"].each_with_object(node.except("allOf")) do |branch, acc|
|
|
85
|
+
merge_branch!(acc, Schema.new(@document, branch).send(:merged))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def merge_branch!(acc, resolved)
|
|
90
|
+
acc["properties"] = (acc["properties"] || {}).merge(resolved["properties"] || {})
|
|
91
|
+
acc["required"] = Array(acc["required"]) | Array(resolved["required"])
|
|
92
|
+
acc["type"] ||= resolved["type"]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def property_rows(prop_node, name:, required:, cursor:)
|
|
96
|
+
dotted = cursor.dotted(name)
|
|
97
|
+
resolved = deref(prop_node)
|
|
98
|
+
ref = prop_node.is_a?(Hash) ? prop_node["$ref"] : nil
|
|
99
|
+
own_row = row(dotted, prop_node, required)
|
|
100
|
+
|
|
101
|
+
# A $ref cycle, or a leaf: emit the row without descending.
|
|
102
|
+
return [own_row] if (ref && cursor.seen.include?(ref)) || !object_with_properties?(resolved)
|
|
103
|
+
|
|
104
|
+
child = Schema.new(@document, resolved).rows(cursor: cursor.descend(dotted, ref))
|
|
105
|
+
[own_row, *child]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def row(name, prop_node, required)
|
|
109
|
+
schema = Schema.new(@document, prop_node)
|
|
110
|
+
{
|
|
111
|
+
name: name,
|
|
112
|
+
type: schema.type_label,
|
|
113
|
+
required: required,
|
|
114
|
+
description: description_cell(deref(prop_node))
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# An OpenAPI description is CommonMark, so hand it to FieldTable as an inline
|
|
119
|
+
# Markdown cell ([:md, ...]); a description-less property gets no cell (the
|
|
120
|
+
# component falls back to the em-dash).
|
|
121
|
+
def description_cell(node)
|
|
122
|
+
desc = node.is_a?(Hash) ? node["description"] : nil
|
|
123
|
+
desc && !desc.to_s.strip.empty? ? [:md, desc.to_s] : nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def synthesize(node, depth, seen)
|
|
127
|
+
# A typeless node with properties is an implicit object.
|
|
128
|
+
return synthesize_object(node, depth, seen) if node["type"] == "object" || node["properties"]
|
|
129
|
+
|
|
130
|
+
case node["type"]
|
|
131
|
+
when "array" then [synthesize_array_item(node, depth, seen)]
|
|
132
|
+
when "integer", "number" then 0
|
|
133
|
+
when "boolean" then true
|
|
134
|
+
else "string"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def synthesize_array_item(node, depth, seen)
|
|
139
|
+
Schema.new(@document, deref(node["items"])).example_value(depth: depth + 1, seen: seen)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def synthesize_object(node, depth, seen)
|
|
143
|
+
props = node["properties"]
|
|
144
|
+
return {} unless props.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
props.each_with_object({}) do |(name, prop_node), acc|
|
|
147
|
+
ref = prop_node.is_a?(Hash) ? prop_node["$ref"] : nil
|
|
148
|
+
next if ref && seen.include?(ref) # cycle: drop the looping property
|
|
149
|
+
|
|
150
|
+
acc[name] = Schema.new(@document, prop_node)
|
|
151
|
+
.example_value(depth: depth + 1, seen: seen + [ref].compact)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def array?(node)
|
|
156
|
+
node["type"] == "array" || node.key?("items")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def object_with_properties?(node)
|
|
160
|
+
node.is_a?(Hash) && node["properties"].is_a?(Hash)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def enum_label(values)
|
|
164
|
+
Array(values).join(" | ")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def one_of_label(node)
|
|
168
|
+
branches = node["oneOf"] || node["anyOf"]
|
|
169
|
+
labels = branches.map { |b| Schema.new(@document, b).type_label }
|
|
170
|
+
"one of: #{labels.join(' | ')}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def deref(node)
|
|
174
|
+
@document.deref(node)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module DocsKit
|
|
8
|
+
# The OpenAPI-bridge entry point. Loads an OpenAPI 3.x spec (a file path or an
|
|
9
|
+
# already-parsed Hash) into a narrow, gem-owned object model — a
|
|
10
|
+
# DocsKit::OpenApi::Document yielding DocsKit::OpenApi::Operation objects — that
|
|
11
|
+
# exposes ONLY what the existing render targets consume (FieldTable rows,
|
|
12
|
+
# ErrorTable rows, example bodies, code samples). No runtime parser dependency:
|
|
13
|
+
# YAML via Psych, JSON via the stdlib.
|
|
14
|
+
#
|
|
15
|
+
# doc = DocsKit::OpenApi.load("openapi.yaml")
|
|
16
|
+
# op = doc.operation("createInvoice") # or doc.operation(:post, "/v1/invoices")
|
|
17
|
+
# op.body_rows # => [{ name:, type:, required:, description: }, ...] (FieldTable)
|
|
18
|
+
# op.error_rows # => [{ scenario:, status:, type: }, ...] (ErrorTable)
|
|
19
|
+
# op.success_example# => a Hash → JsonResponse
|
|
20
|
+
#
|
|
21
|
+
# DocsUI::OpenApiOperation renders one Operation through the kit; the `operation`
|
|
22
|
+
# page helper looks it up on DocsKit.configuration.openapi_document.
|
|
23
|
+
module OpenApi
|
|
24
|
+
# Raised when a requested operation isn't in the spec. The message lists the
|
|
25
|
+
# available operationIds so a typo is diagnosable at the call site.
|
|
26
|
+
class OperationNotFound < DocsKit::Error; end
|
|
27
|
+
|
|
28
|
+
# Raised on a $ref this bridge intentionally doesn't resolve — an external or
|
|
29
|
+
# remote reference (anything not starting "#/"). The message names the ref.
|
|
30
|
+
class UnsupportedRef < DocsKit::Error; end
|
|
31
|
+
|
|
32
|
+
# YAML types a real-world spec might legitimately carry (dates in examples).
|
|
33
|
+
PERMITTED_YAML_CLASSES = [Date, Time].freeze
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Load a spec from a String/Pathname path (`.json` parsed as JSON, anything
|
|
38
|
+
# else as YAML) or from an already-parsed Hash. Returns a Document.
|
|
39
|
+
def load(source)
|
|
40
|
+
Document.new(source.is_a?(Hash) ? source : parse_file(source))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Parse a file by extension: JSON for `.json`, YAML (with alias support)
|
|
44
|
+
# otherwise. Kept module-level so Document#load-style reloads share it.
|
|
45
|
+
def parse_file(path)
|
|
46
|
+
pathname = Pathname.new(path)
|
|
47
|
+
contents = pathname.read
|
|
48
|
+
if pathname.extname.casecmp?(".json")
|
|
49
|
+
JSON.parse(contents)
|
|
50
|
+
else
|
|
51
|
+
YAML.safe_load(contents, aliases: true, permitted_classes: PERMITTED_YAML_CLASSES)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# parameterize/camelize/underscore/safe_constantize for the v2 `page` DSL. A
|
|
4
|
+
# host Rails app already loads these; the gem requires them explicitly so the
|
|
5
|
+
# registry derives slugs/views even when loaded standalone (the suite, a plain
|
|
6
|
+
# Ruby consumer).
|
|
7
|
+
require "active_support/core_ext/string/inflections"
|
|
8
|
+
|
|
9
|
+
module DocsKit
|
|
10
|
+
# A mixin for an in-memory docs registry (guides, demos, component references).
|
|
11
|
+
#
|
|
12
|
+
# Two authoring styles, one shared lookup/grouping API:
|
|
13
|
+
#
|
|
14
|
+
# 1. The one-line `page` DSL (v2) — the default a site should reach for. slug
|
|
15
|
+
# and view derive from the title (both overridable); instances get default
|
|
16
|
+
# readers + view_class + href for free; the sidebar nav derives from the
|
|
17
|
+
# registry with zero site code:
|
|
18
|
+
#
|
|
19
|
+
# class Doc
|
|
20
|
+
# extend DocsKit::Registry
|
|
21
|
+
# path_prefix "/docs" # href = "#{path_prefix}/#{slug}"
|
|
22
|
+
# view_namespace "Views::Docs::Pages" # view_class resolves under this
|
|
23
|
+
# page "Installation", group: "Guide" # slug "installation", view "Installation"
|
|
24
|
+
# page "Getting started", group: "Guide", icon: "rocket" # slug "getting-started", view "GettingStarted"
|
|
25
|
+
# page "OAuth", group: "Guide", slug: "auth", view: "OauthGuide" # every derivation overridable
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# 2. The low-level hash `entries` API — for a registry with a bespoke schema
|
|
29
|
+
# (custom fields, a non-default view namespace). The site writes its own
|
|
30
|
+
# initialize/readers/view_class:
|
|
31
|
+
#
|
|
32
|
+
# class Demo
|
|
33
|
+
# extend DocsKit::Registry
|
|
34
|
+
# entries [{ slug: "counter", title: "Counter", group: "Examples", view: "Counter" }]
|
|
35
|
+
# attr_reader :slug, :title, :group, :view_name
|
|
36
|
+
# def initialize(entry) = (@slug, @title, @group, @view_name = entry.values_at(:slug, :title, :group, :view))
|
|
37
|
+
# def view_class = "Views::Docs::Pages::#{view_name}".safe_constantize
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# Doc.all # => [entry instances]
|
|
41
|
+
# Doc.from_slug("installation") # => instance | nil
|
|
42
|
+
# Doc.grouped # => { "Guide" => [instances] }
|
|
43
|
+
# Doc.nav_items # => { "Guide" => [NavItem] } (authored pages only)
|
|
44
|
+
#
|
|
45
|
+
# A registry uses ONE style; mixing `page` and `entries` raises Registry::Error.
|
|
46
|
+
module Registry
|
|
47
|
+
# Raised on invalid registry declarations (e.g. mixing `page` and `entries`).
|
|
48
|
+
class Error < DocsKit::Error
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Declares the frozen registry data directly (the low-level hash API). Each
|
|
52
|
+
# entry is a Hash; the site supplies its own instance class behavior.
|
|
53
|
+
def entries(list = nil)
|
|
54
|
+
return @entries if list.nil?
|
|
55
|
+
|
|
56
|
+
raise Error, "cannot mix `page` and `entries` in one registry" if @pages&.any?
|
|
57
|
+
|
|
58
|
+
@entries = list.map(&:freeze).freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Declares one page (the v2 DSL). slug/view derive from the title unless
|
|
62
|
+
# given. Appends to the registry in declaration order (== sidebar order).
|
|
63
|
+
#
|
|
64
|
+
# page "Getting started", group: "Guide", icon: "rocket", slug: "start", view: "Start"
|
|
65
|
+
def page(title, group:, slug: nil, view: nil, icon: nil)
|
|
66
|
+
raise Error, "cannot mix `page` and `entries` in one registry" if defined?(@entries) && @entries
|
|
67
|
+
|
|
68
|
+
(@pages ||= []) << {
|
|
69
|
+
title: title,
|
|
70
|
+
group: group,
|
|
71
|
+
slug: slug || title.parameterize,
|
|
72
|
+
view: view || title.parameterize(separator: "_").camelize,
|
|
73
|
+
icon: icon
|
|
74
|
+
}.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# href = "#{path_prefix}/#{slug}". Defaults to "/docs".
|
|
78
|
+
def path_prefix(value = nil)
|
|
79
|
+
@path_prefix = value if value
|
|
80
|
+
@path_prefix || "/docs"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The namespace a page's view_class resolves under (v2 pages only), e.g.
|
|
84
|
+
# "Views::Docs::Pages". Required to resolve views via the default Entry.
|
|
85
|
+
def view_namespace(value = nil)
|
|
86
|
+
@view_namespace = value if value
|
|
87
|
+
@view_namespace
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The attribute used by #grouped (default :group).
|
|
91
|
+
def group_by_attribute(attr = nil)
|
|
92
|
+
@group_by_attribute = attr if attr
|
|
93
|
+
@group_by_attribute || :group
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# All registry instances (built fresh each call — instances are cheap and a
|
|
97
|
+
# site may resolve view classes that change under code reload in development).
|
|
98
|
+
# v2 pages are wrapped in the default Entry; hash entries in the site's class.
|
|
99
|
+
def all
|
|
100
|
+
if defined?(@pages) && @pages
|
|
101
|
+
@pages.map { |attrs| Entry.new(attrs, path_prefix, view_namespace) }
|
|
102
|
+
else
|
|
103
|
+
(entries || []).map { |entry| new(entry) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The instance whose slug matches, or nil.
|
|
108
|
+
def from_slug(slug)
|
|
109
|
+
all.find { |item| item.slug.to_s == slug.to_s }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# { group_value => [instances] }, preserving registry order within a group.
|
|
113
|
+
def grouped
|
|
114
|
+
all.group_by { |item| item.public_send(group_by_attribute) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# { group => [NavItem] } for authored pages only (a resolvable view_class),
|
|
118
|
+
# so the sidebar never links a page that isn't written yet. This is the
|
|
119
|
+
# transform every site used to hand-write in its nav lambda.
|
|
120
|
+
def nav_items
|
|
121
|
+
all.select { |item| item.respond_to?(:view_class) && item.view_class }
|
|
122
|
+
.group_by { |item| item.public_send(group_by_attribute) }
|
|
123
|
+
.transform_values do |items|
|
|
124
|
+
items.map { |item| DocsKit::NavItem.new(href: item.href, label: item.title, icon: item.icon) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The default instance for a v2 `page` entry: readers + href + view_class
|
|
129
|
+
# resolved under the registry's view_namespace (nil until the class exists,
|
|
130
|
+
# preserving the no-dead-links behavior).
|
|
131
|
+
class Entry
|
|
132
|
+
attr_reader :slug, :title, :group, :icon, :view_name, :href
|
|
133
|
+
|
|
134
|
+
def initialize(attrs, path_prefix, view_namespace)
|
|
135
|
+
@slug = attrs[:slug]
|
|
136
|
+
@title = attrs[:title]
|
|
137
|
+
@group = attrs[:group]
|
|
138
|
+
@icon = attrs[:icon]
|
|
139
|
+
@view_name = attrs[:view]
|
|
140
|
+
@view_namespace = view_namespace
|
|
141
|
+
@href = "#{path_prefix}/#{@slug}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The authored Phlex page class, or nil until it's written.
|
|
145
|
+
def view_class
|
|
146
|
+
return unless @view_namespace
|
|
147
|
+
|
|
148
|
+
"#{@view_namespace}::#{@view_name}".safe_constantize
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|