docs-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +939 -0
  5. data/app/components/docs_ui/brand_mark.rb +88 -0
  6. data/app/components/docs_ui/callout.rb +37 -0
  7. data/app/components/docs_ui/code.rb +123 -0
  8. data/app/components/docs_ui/endpoint.rb +44 -0
  9. data/app/components/docs_ui/error_table.rb +72 -0
  10. data/app/components/docs_ui/example.rb +102 -0
  11. data/app/components/docs_ui/field_table.rb +46 -0
  12. data/app/components/docs_ui/header.rb +30 -0
  13. data/app/components/docs_ui/icon.rb +65 -0
  14. data/app/components/docs_ui/json_response.rb +46 -0
  15. data/app/components/docs_ui/markdown.rb +187 -0
  16. data/app/components/docs_ui/markdown_action.rb +45 -0
  17. data/app/components/docs_ui/on_this_page.rb +104 -0
  18. data/app/components/docs_ui/open_api_operation.rb +126 -0
  19. data/app/components/docs_ui/page.rb +83 -0
  20. data/app/components/docs_ui/page_helpers.rb +52 -0
  21. data/app/components/docs_ui/prop_table.rb +43 -0
  22. data/app/components/docs_ui/prose.rb +30 -0
  23. data/app/components/docs_ui/request_example.rb +85 -0
  24. data/app/components/docs_ui/search_box.rb +106 -0
  25. data/app/components/docs_ui/search_results.rb +95 -0
  26. data/app/components/docs_ui/section.rb +94 -0
  27. data/app/components/docs_ui/shell.rb +161 -0
  28. data/app/components/docs_ui/sidebar.rb +106 -0
  29. data/app/components/docs_ui/table.rb +64 -0
  30. data/app/components/docs_ui/theme_switcher.rb +46 -0
  31. data/app/components/docs_ui/topbar_links.rb +42 -0
  32. data/app/controllers/docs_kit/llms_controller.rb +76 -0
  33. data/app/controllers/docs_kit/mcp_controller.rb +60 -0
  34. data/app/controllers/docs_kit/search_controller.rb +72 -0
  35. data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
  36. data/config/importmap.rb +15 -0
  37. data/config/rubocop/docs_kit.yml +24 -0
  38. data/exe/docs-kit +80 -0
  39. data/lib/docs-kit.rb +5 -0
  40. data/lib/docs_kit/api_client.rb +52 -0
  41. data/lib/docs_kit/api_request.rb +66 -0
  42. data/lib/docs_kit/api_templates.rb +92 -0
  43. data/lib/docs_kit/configuration.rb +485 -0
  44. data/lib/docs_kit/controller.rb +47 -0
  45. data/lib/docs_kit/engine.rb +49 -0
  46. data/lib/docs_kit/llms_text.rb +105 -0
  47. data/lib/docs_kit/markdown_export/blocks.rb +160 -0
  48. data/lib/docs_kit/markdown_export/inline.rb +95 -0
  49. data/lib/docs_kit/markdown_export/table.rb +53 -0
  50. data/lib/docs_kit/markdown_export.rb +92 -0
  51. data/lib/docs_kit/mcp_server.rb +128 -0
  52. data/lib/docs_kit/mcp_tools.rb +118 -0
  53. data/lib/docs_kit/nav_item.rb +22 -0
  54. data/lib/docs_kit/open_api/document.rb +91 -0
  55. data/lib/docs_kit/open_api/operation.rb +213 -0
  56. data/lib/docs_kit/open_api/schema.rb +178 -0
  57. data/lib/docs_kit/open_api.rb +55 -0
  58. data/lib/docs_kit/registry.rb +152 -0
  59. data/lib/docs_kit/rubocop.rb +19 -0
  60. data/lib/docs_kit/search_hit.rb +28 -0
  61. data/lib/docs_kit/search_index/snippet.rb +65 -0
  62. data/lib/docs_kit/search_index.rb +169 -0
  63. data/lib/docs_kit/shortcut.rb +99 -0
  64. data/lib/docs_kit/templates/new_site.rb +175 -0
  65. data/lib/docs_kit/topbar_link.rb +39 -0
  66. data/lib/docs_kit/version.rb +5 -0
  67. data/lib/docs_kit.rb +72 -0
  68. data/lib/generators/docs_kit/install/USAGE +15 -0
  69. data/lib/generators/docs_kit/install/install_generator.rb +447 -0
  70. data/lib/generators/docs_kit/install/sync_report.rb +64 -0
  71. data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
  72. data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
  73. data/lib/generators/docs_kit/install/templates/build-css +34 -0
  74. data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
  75. data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
  76. data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
  77. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
  78. data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
  79. data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
  80. data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
  81. data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
  82. data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
  83. data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
  84. data/lib/generators/docs_kit/page/USAGE +26 -0
  85. data/lib/generators/docs_kit/page/page_generator.rb +127 -0
  86. data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
  87. data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
  88. data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
  89. metadata +253 -0
@@ -0,0 +1,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