apiwork 0.0.0.pre → 0.1.1

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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +2 -2
  3. data/README.md +117 -1
  4. data/Rakefile +5 -3
  5. data/app/controllers/apiwork/errors_controller.rb +13 -0
  6. data/app/controllers/apiwork/exports_controller.rb +22 -0
  7. data/lib/apiwork/abstractable.rb +26 -0
  8. data/lib/apiwork/adapter/base.rb +369 -0
  9. data/lib/apiwork/adapter/builder/api/base.rb +66 -0
  10. data/lib/apiwork/adapter/builder/contract/base.rb +86 -0
  11. data/lib/apiwork/adapter/capability/api/base.rb +51 -0
  12. data/lib/apiwork/adapter/capability/api/scope.rb +64 -0
  13. data/lib/apiwork/adapter/capability/base.rb +291 -0
  14. data/lib/apiwork/adapter/capability/contract/base.rb +37 -0
  15. data/lib/apiwork/adapter/capability/contract/scope.rb +110 -0
  16. data/lib/apiwork/adapter/capability/operation/base.rb +172 -0
  17. data/lib/apiwork/adapter/capability/operation/metadata_shape.rb +165 -0
  18. data/lib/apiwork/adapter/capability/result.rb +21 -0
  19. data/lib/apiwork/adapter/capability/runner.rb +56 -0
  20. data/lib/apiwork/adapter/capability/transformer/request/base.rb +72 -0
  21. data/lib/apiwork/adapter/capability/transformer/response/base.rb +45 -0
  22. data/lib/apiwork/adapter/registry.rb +16 -0
  23. data/lib/apiwork/adapter/serializer/error/base.rb +72 -0
  24. data/lib/apiwork/adapter/serializer/error/default/api_builder.rb +32 -0
  25. data/lib/apiwork/adapter/serializer/error/default.rb +37 -0
  26. data/lib/apiwork/adapter/serializer/resource/base.rb +84 -0
  27. data/lib/apiwork/adapter/serializer/resource/default/contract_builder.rb +209 -0
  28. data/lib/apiwork/adapter/serializer/resource/default.rb +39 -0
  29. data/lib/apiwork/adapter/standard/capability/filtering/api_builder.rb +75 -0
  30. data/lib/apiwork/adapter/standard/capability/filtering/constants.rb +37 -0
  31. data/lib/apiwork/adapter/standard/capability/filtering/contract_builder.rb +193 -0
  32. data/lib/apiwork/adapter/standard/capability/filtering/operation/filter/builder.rb +47 -0
  33. data/lib/apiwork/adapter/standard/capability/filtering/operation/filter/operator_builder.rb +36 -0
  34. data/lib/apiwork/adapter/standard/capability/filtering/operation/filter.rb +462 -0
  35. data/lib/apiwork/adapter/standard/capability/filtering/operation.rb +22 -0
  36. data/lib/apiwork/adapter/standard/capability/filtering/request_transformer.rb +47 -0
  37. data/lib/apiwork/adapter/standard/capability/filtering.rb +18 -0
  38. data/lib/apiwork/adapter/standard/capability/including/contract_builder.rb +169 -0
  39. data/lib/apiwork/adapter/standard/capability/including/operation.rb +20 -0
  40. data/lib/apiwork/adapter/standard/capability/including.rb +16 -0
  41. data/lib/apiwork/adapter/standard/capability/pagination/api_builder.rb +34 -0
  42. data/lib/apiwork/adapter/standard/capability/pagination/contract_builder.rb +35 -0
  43. data/lib/apiwork/adapter/standard/capability/pagination/operation/paginate/cursor.rb +84 -0
  44. data/lib/apiwork/adapter/standard/capability/pagination/operation/paginate/offset.rb +66 -0
  45. data/lib/apiwork/adapter/standard/capability/pagination/operation/paginate.rb +24 -0
  46. data/lib/apiwork/adapter/standard/capability/pagination/operation.rb +24 -0
  47. data/lib/apiwork/adapter/standard/capability/pagination.rb +21 -0
  48. data/lib/apiwork/adapter/standard/capability/sorting/api_builder.rb +19 -0
  49. data/lib/apiwork/adapter/standard/capability/sorting/contract_builder.rb +84 -0
  50. data/lib/apiwork/adapter/standard/capability/sorting/operation/sort.rb +83 -0
  51. data/lib/apiwork/adapter/standard/capability/sorting/operation.rb +22 -0
  52. data/lib/apiwork/adapter/standard/capability/sorting.rb +17 -0
  53. data/lib/apiwork/adapter/standard/capability/writing/constants.rb +15 -0
  54. data/lib/apiwork/adapter/standard/capability/writing/contract_builder.rb +253 -0
  55. data/lib/apiwork/adapter/standard/capability/writing/operation/issue_mapper.rb +210 -0
  56. data/lib/apiwork/adapter/standard/capability/writing/operation.rb +32 -0
  57. data/lib/apiwork/adapter/standard/capability/writing/request_transformer.rb +37 -0
  58. data/lib/apiwork/adapter/standard/capability/writing.rb +17 -0
  59. data/lib/apiwork/adapter/standard/includes_resolver.rb +106 -0
  60. data/lib/apiwork/adapter/standard.rb +22 -0
  61. data/lib/apiwork/adapter/wrapper/base.rb +70 -0
  62. data/lib/apiwork/adapter/wrapper/collection/base.rb +60 -0
  63. data/lib/apiwork/adapter/wrapper/collection/default.rb +47 -0
  64. data/lib/apiwork/adapter/wrapper/error/base.rb +30 -0
  65. data/lib/apiwork/adapter/wrapper/error/default.rb +34 -0
  66. data/lib/apiwork/adapter/wrapper/member/base.rb +58 -0
  67. data/lib/apiwork/adapter/wrapper/member/default.rb +40 -0
  68. data/lib/apiwork/adapter/wrapper/shape.rb +203 -0
  69. data/lib/apiwork/adapter.rb +50 -0
  70. data/lib/apiwork/api/base.rb +802 -0
  71. data/lib/apiwork/api/element.rb +110 -0
  72. data/lib/apiwork/api/enum_registry/definition.rb +51 -0
  73. data/lib/apiwork/api/enum_registry.rb +98 -0
  74. data/lib/apiwork/api/info/contact.rb +67 -0
  75. data/lib/apiwork/api/info/license.rb +50 -0
  76. data/lib/apiwork/api/info/server.rb +50 -0
  77. data/lib/apiwork/api/info.rb +221 -0
  78. data/lib/apiwork/api/object.rb +235 -0
  79. data/lib/apiwork/api/registry.rb +33 -0
  80. data/lib/apiwork/api/representation_registry.rb +76 -0
  81. data/lib/apiwork/api/resource/action.rb +41 -0
  82. data/lib/apiwork/api/resource.rb +648 -0
  83. data/lib/apiwork/api/router.rb +104 -0
  84. data/lib/apiwork/api/type_registry/definition.rb +117 -0
  85. data/lib/apiwork/api/type_registry.rb +99 -0
  86. data/lib/apiwork/api/union.rb +49 -0
  87. data/lib/apiwork/api.rb +85 -0
  88. data/lib/apiwork/configurable.rb +71 -0
  89. data/lib/apiwork/configuration/option.rb +125 -0
  90. data/lib/apiwork/configuration/validatable.rb +25 -0
  91. data/lib/apiwork/configuration.rb +95 -0
  92. data/lib/apiwork/configuration_error.rb +6 -0
  93. data/lib/apiwork/constraint_error.rb +20 -0
  94. data/lib/apiwork/contract/action/request.rb +79 -0
  95. data/lib/apiwork/contract/action/response.rb +87 -0
  96. data/lib/apiwork/contract/action.rb +258 -0
  97. data/lib/apiwork/contract/base.rb +714 -0
  98. data/lib/apiwork/contract/element.rb +130 -0
  99. data/lib/apiwork/contract/object/coercer.rb +194 -0
  100. data/lib/apiwork/contract/object/deserializer.rb +101 -0
  101. data/lib/apiwork/contract/object/transformer.rb +95 -0
  102. data/lib/apiwork/contract/object/validator/result.rb +27 -0
  103. data/lib/apiwork/contract/object/validator.rb +734 -0
  104. data/lib/apiwork/contract/object.rb +566 -0
  105. data/lib/apiwork/contract/request_parser/result.rb +25 -0
  106. data/lib/apiwork/contract/request_parser.rb +72 -0
  107. data/lib/apiwork/contract/response_parser/result.rb +25 -0
  108. data/lib/apiwork/contract/response_parser.rb +35 -0
  109. data/lib/apiwork/contract/union.rb +56 -0
  110. data/lib/apiwork/contract_error.rb +9 -0
  111. data/lib/apiwork/controller.rb +300 -0
  112. data/lib/apiwork/domain_error.rb +13 -0
  113. data/lib/apiwork/element.rb +386 -0
  114. data/lib/apiwork/engine.rb +20 -0
  115. data/lib/apiwork/error.rb +6 -0
  116. data/lib/apiwork/error_code/definition.rb +63 -0
  117. data/lib/apiwork/error_code/registry.rb +18 -0
  118. data/lib/apiwork/error_code.rb +132 -0
  119. data/lib/apiwork/export/base.rb +291 -0
  120. data/lib/apiwork/export/open_api.rb +600 -0
  121. data/lib/apiwork/export/pipeline/writer.rb +66 -0
  122. data/lib/apiwork/export/pipeline.rb +84 -0
  123. data/lib/apiwork/export/registry.rb +16 -0
  124. data/lib/apiwork/export/surface_resolver.rb +189 -0
  125. data/lib/apiwork/export/type_analysis.rb +170 -0
  126. data/lib/apiwork/export/type_script.rb +23 -0
  127. data/lib/apiwork/export/type_script_mapper.rb +349 -0
  128. data/lib/apiwork/export/zod.rb +39 -0
  129. data/lib/apiwork/export/zod_mapper.rb +421 -0
  130. data/lib/apiwork/export.rb +80 -0
  131. data/lib/apiwork/http_error.rb +16 -0
  132. data/lib/apiwork/introspection/action/request.rb +66 -0
  133. data/lib/apiwork/introspection/action/response.rb +57 -0
  134. data/lib/apiwork/introspection/action.rb +124 -0
  135. data/lib/apiwork/introspection/api/info/contact.rb +59 -0
  136. data/lib/apiwork/introspection/api/info/license.rb +49 -0
  137. data/lib/apiwork/introspection/api/info/server.rb +50 -0
  138. data/lib/apiwork/introspection/api/info.rb +107 -0
  139. data/lib/apiwork/introspection/api/resource.rb +83 -0
  140. data/lib/apiwork/introspection/api.rb +92 -0
  141. data/lib/apiwork/introspection/contract.rb +63 -0
  142. data/lib/apiwork/introspection/dump/action.rb +101 -0
  143. data/lib/apiwork/introspection/dump/api.rb +119 -0
  144. data/lib/apiwork/introspection/dump/contract.rb +129 -0
  145. data/lib/apiwork/introspection/dump/param.rb +486 -0
  146. data/lib/apiwork/introspection/dump/resource.rb +112 -0
  147. data/lib/apiwork/introspection/dump/type.rb +339 -0
  148. data/lib/apiwork/introspection/dump.rb +17 -0
  149. data/lib/apiwork/introspection/enum.rb +63 -0
  150. data/lib/apiwork/introspection/error_code.rb +44 -0
  151. data/lib/apiwork/introspection/param/array.rb +88 -0
  152. data/lib/apiwork/introspection/param/base.rb +285 -0
  153. data/lib/apiwork/introspection/param/binary.rb +73 -0
  154. data/lib/apiwork/introspection/param/boolean.rb +73 -0
  155. data/lib/apiwork/introspection/param/date.rb +73 -0
  156. data/lib/apiwork/introspection/param/date_time.rb +73 -0
  157. data/lib/apiwork/introspection/param/decimal.rb +121 -0
  158. data/lib/apiwork/introspection/param/integer.rb +131 -0
  159. data/lib/apiwork/introspection/param/literal.rb +45 -0
  160. data/lib/apiwork/introspection/param/number.rb +121 -0
  161. data/lib/apiwork/introspection/param/object.rb +59 -0
  162. data/lib/apiwork/introspection/param/reference.rb +45 -0
  163. data/lib/apiwork/introspection/param/string.rb +122 -0
  164. data/lib/apiwork/introspection/param/time.rb +73 -0
  165. data/lib/apiwork/introspection/param/union.rb +57 -0
  166. data/lib/apiwork/introspection/param/unknown.rb +26 -0
  167. data/lib/apiwork/introspection/param/uuid.rb +73 -0
  168. data/lib/apiwork/introspection/param.rb +31 -0
  169. data/lib/apiwork/introspection/type.rb +129 -0
  170. data/lib/apiwork/introspection.rb +28 -0
  171. data/lib/apiwork/issue.rb +80 -0
  172. data/lib/apiwork/json_pointer.rb +21 -0
  173. data/lib/apiwork/object.rb +1618 -0
  174. data/lib/apiwork/reference_generator.rb +622 -0
  175. data/lib/apiwork/registry.rb +56 -0
  176. data/lib/apiwork/representation/association.rb +391 -0
  177. data/lib/apiwork/representation/attribute.rb +335 -0
  178. data/lib/apiwork/representation/base.rb +819 -0
  179. data/lib/apiwork/representation/deserializer.rb +95 -0
  180. data/lib/apiwork/representation/element.rb +128 -0
  181. data/lib/apiwork/representation/inheritance.rb +78 -0
  182. data/lib/apiwork/representation/model_detector.rb +75 -0
  183. data/lib/apiwork/representation/root_key.rb +35 -0
  184. data/lib/apiwork/representation/serializer.rb +127 -0
  185. data/lib/apiwork/request.rb +79 -0
  186. data/lib/apiwork/response.rb +56 -0
  187. data/lib/apiwork/union.rb +102 -0
  188. data/lib/apiwork/version.rb +2 -2
  189. data/lib/apiwork.rb +61 -3
  190. data/lib/generators/apiwork/api_generator.rb +38 -0
  191. data/lib/generators/apiwork/contract_generator.rb +25 -0
  192. data/lib/generators/apiwork/install_generator.rb +27 -0
  193. data/lib/generators/apiwork/representation_generator.rb +25 -0
  194. data/lib/generators/apiwork/templates/api/api.rb.tt +4 -0
  195. data/lib/generators/apiwork/templates/contract/contract.rb.tt +6 -0
  196. data/lib/generators/apiwork/templates/install/application_contract.rb.tt +5 -0
  197. data/lib/generators/apiwork/templates/install/application_representation.rb.tt +5 -0
  198. data/lib/generators/apiwork/templates/representation/representation.rb.tt +6 -0
  199. data/lib/tasks/apiwork.rake +102 -0
  200. metadata +319 -19
  201. data/.rubocop.yml +0 -8
  202. data/sig/apiwork.rbs +0 -4
@@ -0,0 +1,622 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yard'
4
+ require 'fileutils'
5
+ require 'active_support/core_ext/string/inflections'
6
+ require 'active_support/core_ext/object/blank'
7
+
8
+ module Apiwork
9
+ class ReferenceGenerator
10
+ GEM_ROOT = File.expand_path('../..', __dir__)
11
+ OUTPUT_DIR = File.join(GEM_ROOT, 'docs/reference')
12
+ GITHUB_URL = 'https://github.com/skiftle/apiwork/blob/main'
13
+ RUBY_PRIMITIVES = %w[
14
+ String Integer Float Boolean Symbol Hash Array Object
15
+ TrueClass FalseClass NilClass Numeric Proc
16
+ ].to_set.freeze
17
+
18
+ class << self
19
+ def generate
20
+ new.generate
21
+ end
22
+ end
23
+
24
+ def generate
25
+ parse_source
26
+ modules = extract_modules
27
+ write_files(modules)
28
+ end
29
+
30
+ private
31
+
32
+ def parse_source
33
+ YARD::Registry.clear
34
+ YARD.parse(File.join(GEM_ROOT, 'lib/**/*.rb'))
35
+ end
36
+
37
+ def public_api_yard_objects
38
+ @public_api_yard_objects ||= YARD::Registry.all(:class, :module)
39
+ .select { |yard_object| yard_object.path.start_with?('Apiwork') && public_api?(yard_object) }
40
+ end
41
+
42
+ def extract_modules
43
+ public_api_yard_objects
44
+ .map { |yard_object| serialize_module(yard_object) }
45
+ .reject { |mod| mod[:class_methods].empty? && mod[:instance_methods].empty? }
46
+ .sort_by { |mod| mod[:path] }
47
+ end
48
+
49
+ def public_api?(yard_object)
50
+ api_tag = yard_object.docstring.tags(:api).find { |tag| tag.text == 'public' }
51
+
52
+ if yard_object.type == :method
53
+ return false unless api_tag
54
+
55
+ parent = yard_object.namespace
56
+ return true unless parent
57
+
58
+ parent_api_tag = parent.docstring.tags(:api).find { |tag| tag.text == 'public' }
59
+ return api_tag.object_id != parent_api_tag&.object_id
60
+ end
61
+
62
+ has_own_docstring = !yard_object.docstring.to_s.strip.empty?
63
+ return true if has_own_docstring && api_tag
64
+
65
+ return false unless yard_object.file
66
+
67
+ lines = File.readlines(yard_object.file)
68
+ docstring_range = yard_object.docstring.line_range
69
+
70
+ start_line = if docstring_range
71
+ docstring_range.first - 1
72
+ else
73
+ [yard_object.line - 5, 0].max
74
+ end
75
+ end_line = yard_object.line
76
+
77
+ preceding_lines = lines[start_line...end_line].join
78
+ preceding_lines.include?('@api public')
79
+ end
80
+
81
+ def serialize_module(yard_object)
82
+ {
83
+ class_methods: extract_methods(yard_object, :class),
84
+ docstring: yard_object.docstring.to_s,
85
+ examples: extract_examples(yard_object),
86
+ file: relative_path(yard_object.file),
87
+ instance_methods: extract_methods(yard_object, :instance),
88
+ line: yard_object.line,
89
+ name: yard_object.name.to_s,
90
+ path: yard_object.path,
91
+ type: yard_object.type,
92
+ }
93
+ end
94
+
95
+ def relative_path(file)
96
+ return nil unless file
97
+
98
+ file.delete_prefix("#{GEM_ROOT}/")
99
+ end
100
+
101
+ def extract_methods(yard_object, scope)
102
+ methods = yard_object.meths(scope:, visibility: :public)
103
+
104
+ yard_object.mixins(:instance).each do |mixin|
105
+ mixin_yard_object = YARD::Registry.at(mixin.path)
106
+ next unless mixin_yard_object
107
+
108
+ methods += mixin_yard_object.meths(scope:, visibility: :public)
109
+ end
110
+
111
+ methods
112
+ .select { |method| public_api?(method) && documented?(method) }
113
+ .uniq(&:name)
114
+ .sort_by(&:name)
115
+ .map { |method| serialize_method(method) }
116
+ end
117
+
118
+ def documented?(method)
119
+ return true unless method.docstring.to_s.strip.empty?
120
+
121
+ method.docstring.tags.any? do |tag|
122
+ next false if tag.tag_name == 'api'
123
+ next true if tag.tag_name == 'see' && tag.name.present?
124
+ next true if tag.tag_name == 'return' && tag.types.present?
125
+ next true if tag.tag_name == 'param' && tag.types.present?
126
+
127
+ tag.text.to_s.strip.present?
128
+ end
129
+ end
130
+
131
+ def serialize_method(method)
132
+ {
133
+ docstring: method.docstring.to_s,
134
+ examples: extract_examples(method),
135
+ file: relative_path(method.file),
136
+ line: method.line,
137
+ name: method.name.to_s,
138
+ params: extract_params(method),
139
+ returns: extract_return(method),
140
+ see: extract_see(method),
141
+ signature: build_signature(method),
142
+ summary: method.docstring.summary,
143
+ yieldparams: extract_yieldparams(method),
144
+ }
145
+ end
146
+
147
+ def build_signature(method)
148
+ params = method.parameters.reject { |name, _default| name.to_s.start_with?('internal') }
149
+ params = params.map do |name, default|
150
+ if name.to_s.end_with?(':')
151
+ default ? "#{name} #{default}" : name.to_s
152
+ else
153
+ default ? "#{name} = #{default}" : name.to_s
154
+ end
155
+ end
156
+
157
+ name = escape_brackets(method.name.to_s)
158
+ params.any? ? "#{name}(#{params.join(', ')})" : name
159
+ end
160
+
161
+ def escape_brackets(text)
162
+ text.gsub('[', '\\[').gsub(']', '\\]')
163
+ end
164
+
165
+ def extract_params(method)
166
+ method.docstring.tags(:param).map do |tag|
167
+ parsed = parse_param_description(tag.text)
168
+ {
169
+ default: parsed[:default],
170
+ description: parsed[:description],
171
+ name: tag.name,
172
+ types: tag.types || [],
173
+ values: parsed[:values],
174
+ values_type: parsed[:values_type],
175
+ }
176
+ end
177
+ end
178
+
179
+ def format_param_type(param)
180
+ replace_type = param[:values_type] || 'Symbol'
181
+
182
+ types = param[:types].map do |t|
183
+ if param[:values]&.any? && t == replace_type
184
+ values = param[:values].join(', ').gsub('|', '\|')
185
+ "`#{t}<#{values}>`"
186
+ else
187
+ "`#{t}`"
188
+ end
189
+ end
190
+
191
+ types.join(', ')
192
+ end
193
+
194
+ def parse_param_description(text)
195
+ return { default: nil, description: nil, values: nil, values_type: nil } if text.blank?
196
+
197
+ match = text.match(/\A(?:\(([^)]+)\))?\s*(?:\[(?:(\w+):\s*)?([^\]]+)\])?\s*(.*)\z/m)
198
+ {
199
+ default: match[1],
200
+ description: match[4].presence,
201
+ values: match[3]&.split(/,\s*/),
202
+ values_type: match[2],
203
+ }
204
+ end
205
+
206
+ def extract_return(method)
207
+ tag = method.docstring.tag(:return)
208
+ return nil unless tag
209
+
210
+ {
211
+ description: tag.text,
212
+ types: tag.types || [],
213
+ }
214
+ end
215
+
216
+ def extract_examples(method)
217
+ method.docstring.tags(:example).map do |tag|
218
+ {
219
+ code: tag.text,
220
+ title: tag.name,
221
+ }
222
+ end
223
+ end
224
+
225
+ def extract_see(method)
226
+ method.docstring.tags(:see).map(&:name)
227
+ end
228
+
229
+ def extract_yieldparams(method)
230
+ method.docstring.tags(:yieldparam).map do |tag|
231
+ { name: tag.name, types: tag.types || [] }
232
+ end
233
+ end
234
+
235
+ def write_files(modules)
236
+ cleanup_old_files
237
+ @modules = modules
238
+ @modules_with_children = build_modules_with_children(modules)
239
+
240
+ modules.each.with_index(1) do |mod, order|
241
+ filepath = module_filepath(mod[:path])
242
+ FileUtils.mkdir_p(File.dirname(filepath))
243
+ content = render_module(mod, order)
244
+ File.write(filepath, content)
245
+ end
246
+
247
+ write_namespace_indexes
248
+ end
249
+
250
+ def write_namespace_indexes
251
+ collect_all_folder_paths.each do |folder_parts|
252
+ folder_path = File.join(OUTPUT_DIR, *folder_parts.map { |p| dasherize(p) })
253
+ index_path = File.join(folder_path, 'index.md')
254
+
255
+ next if File.exist?(index_path)
256
+
257
+ title = folder_parts.last
258
+ parent_path = "Apiwork::#{folder_parts.join('::')}"
259
+ children = find_direct_children(parent_path)
260
+ first_child = @modules.index { |m| m[:path].start_with?("#{parent_path}::") }
261
+ order = first_child + 1
262
+
263
+ content = render_namespace_index(title, parent_path, children, order)
264
+ FileUtils.mkdir_p(folder_path)
265
+ File.write(index_path, content)
266
+ end
267
+ end
268
+
269
+ def collect_all_folder_paths
270
+ paths = Set.new
271
+
272
+ @modules.each do |mod|
273
+ parts = mod[:path].sub('Apiwork::', '').split('::')
274
+
275
+ (1...parts.size).each do |i|
276
+ paths << parts[0...i]
277
+ end
278
+
279
+ paths << parts if @modules_with_children.include?(mod[:path])
280
+ end
281
+
282
+ paths.to_a.sort_by(&:size)
283
+ end
284
+
285
+ def find_direct_children(parent_path)
286
+ @modules
287
+ .select { |m| m[:path].start_with?("#{parent_path}::") }
288
+ .map do |m|
289
+ remaining = m[:path].sub("#{parent_path}::", '')
290
+ remaining.split('::').first
291
+ end
292
+ .uniq
293
+ .sort
294
+ end
295
+
296
+ def render_namespace_index(title, parent_path, children, order)
297
+ parts = []
298
+ parts << "---\norder: #{order}\nprev: false\nnext: false\n---\n"
299
+ parts << "# #{title}\n"
300
+
301
+ if children.any?
302
+ parts << "## Modules\n"
303
+ render_child_links(parts, parent_path, children)
304
+ end
305
+
306
+ parts.join("\n")
307
+ end
308
+
309
+ def render_child_links(parts, parent_path, children)
310
+ children.each do |child|
311
+ child_path = dasherize(child)
312
+ child_full_path = "#{parent_path}::#{child}"
313
+ folder = @modules_with_children.include?(child_full_path) ||
314
+ @modules.none? { |m| m[:path] == child_full_path }
315
+ link = folder ? "./#{child_path}/" : "./#{child_path}"
316
+ parts << "- [#{child}](#{link})"
317
+ end
318
+ parts << ''
319
+ end
320
+
321
+ def render_examples(parts, examples)
322
+ examples.each do |example|
323
+ title_suffix = example[:title].blank? ? '' : ": #{example[:title]}"
324
+ parts << "**Example#{title_suffix}**\n"
325
+ parts << '```ruby'
326
+ parts << example[:code]
327
+ parts << "```\n"
328
+ end
329
+ end
330
+
331
+ def build_modules_with_children(modules)
332
+ paths = modules.map { |m| m[:path] }.to_set
333
+ paths.select do |path|
334
+ paths.any? { |other| other != path && other.start_with?("#{path}::") }
335
+ end.to_set
336
+ end
337
+
338
+ def cleanup_old_files
339
+ Dir.glob(File.join(OUTPUT_DIR, '**/*')).each do |entry|
340
+ next if entry == File.join(OUTPUT_DIR, 'index.md')
341
+
342
+ FileUtils.rm_rf(entry)
343
+ end
344
+ end
345
+
346
+ def module_filepath(path)
347
+ parts = path.sub('Apiwork::', '').split('::')
348
+ return File.join(OUTPUT_DIR, 'index.md') if parts.empty?
349
+
350
+ file_parts = parts.map { |part| dasherize(part) }
351
+
352
+ if @modules_with_children.include?(path)
353
+ File.join(OUTPUT_DIR, *file_parts, 'index.md')
354
+ else
355
+ folders = file_parts[0..-2]
356
+ filename = "#{file_parts.last}.md"
357
+ File.join(OUTPUT_DIR, *folders, filename)
358
+ end
359
+ end
360
+
361
+ def display_title(path)
362
+ path.sub('Apiwork::', '').split('::').last
363
+ end
364
+
365
+ def dasherize(string)
366
+ string.underscore.dasherize
367
+ end
368
+
369
+ def linkify_yard_refs(text)
370
+ return text if text.blank?
371
+
372
+ result = text.gsub(/\{([^}]+)\}/) do
373
+ ref = ::Regexp.last_match(1)
374
+ link_path = yard_ref_to_path(ref)
375
+ "[#{ref}](#{link_path})"
376
+ end
377
+ escape_html(result)
378
+ end
379
+
380
+ def escape_html(text)
381
+ return text if text.blank?
382
+
383
+ text.gsub('<', '&lt;').gsub('>', '&gt;')
384
+ end
385
+
386
+ def linkify_type(type_str)
387
+ parsed = yard_type_parser.parse(type_str)
388
+ names = extract_type_names(parsed)
389
+ linkable_names = names.select { |name| linkable_type?(name) }
390
+
391
+ if linkable_names.empty?
392
+ "`#{type_str}`"
393
+ else
394
+ result = escape_html(type_str)
395
+ linkable_names.each do |name|
396
+ result.gsub!(/\b#{Regexp.escape(name)}\b/, "[#{name}](#{class_to_filepath(name)})")
397
+ end
398
+ result
399
+ end
400
+ rescue StandardError
401
+ "`#{type_str}`"
402
+ end
403
+
404
+ def yard_type_parser
405
+ YARD::Tags::TypesExplainer::Parser
406
+ end
407
+
408
+ def extract_type_names(types)
409
+ types.flat_map do |type|
410
+ case type
411
+ when YARD::Tags::TypesExplainer::HashCollectionType
412
+ [type.name] + extract_type_names(type.key_types) + extract_type_names(type.value_types)
413
+ when YARD::Tags::TypesExplainer::CollectionType, YARD::Tags::TypesExplainer::FixedCollectionType
414
+ [type.name] + extract_type_names(type.types)
415
+ else
416
+ [type.name]
417
+ end
418
+ end.uniq
419
+ end
420
+
421
+ def linkable_type?(type_name)
422
+ return false if RUBY_PRIMITIVES.include?(type_name)
423
+
424
+ @linkable_types ||= build_linkable_types
425
+ @linkable_types.include?(type_name)
426
+ end
427
+
428
+ def build_linkable_types
429
+ public_api_yard_objects
430
+ .flat_map do |yard_object|
431
+ path = yard_object.path.delete_prefix('Apiwork::')
432
+ parts = path.split('::')
433
+ Array.new(parts.size) { |index| parts[index..].join('::') }
434
+ end
435
+ .reject { |name| RUBY_PRIMITIVES.include?(name) }
436
+ .to_set
437
+ end
438
+
439
+ def type_path_lookup
440
+ @type_path_lookup ||= build_type_path_lookup
441
+ end
442
+
443
+ def build_type_path_lookup
444
+ lookup = {}
445
+ public_api_yard_objects.each do |yard_object|
446
+ full_path = yard_object.path.delete_prefix('Apiwork::')
447
+ parts = full_path.split('::')
448
+
449
+ parts.size.times do |index|
450
+ partial_path = parts[index..].join('::')
451
+ next if RUBY_PRIMITIVES.include?(partial_path)
452
+
453
+ lookup[partial_path] ||= full_path
454
+ end
455
+ end
456
+
457
+ lookup
458
+ end
459
+
460
+ def yard_ref_to_path(ref)
461
+ if ref.start_with?('#', '.')
462
+ "##{ref[1..].dasherize}"
463
+ elsif ref.include?('#')
464
+ class_part, method_part = ref.split('#', 2)
465
+ "#{class_to_filepath(class_part)}##{method_part.dasherize}"
466
+ elsif ref.include?('.')
467
+ class_part, method_part = ref.split('.', 2)
468
+ "#{class_to_filepath(class_part)}##{method_part.dasherize}"
469
+ else
470
+ class_to_filepath(ref)
471
+ end
472
+ end
473
+
474
+ def see_ref_linkable?(ref)
475
+ return true if ref.start_with?('#', '.')
476
+
477
+ class_part = ref.split(/[#.]/, 2).first
478
+ linkable_type?(class_part)
479
+ end
480
+
481
+ def class_to_filepath(class_name)
482
+ resolved = type_path_lookup[class_name] || class_name
483
+ without_apiwork = resolved.delete_prefix('Apiwork::')
484
+ parts = without_apiwork.split('::')
485
+
486
+ return '/reference/' if parts.empty?
487
+
488
+ file_parts = parts.map { |part| dasherize(part) }
489
+ full_path = "Apiwork::#{without_apiwork}"
490
+
491
+ if modules_with_children_for_links.include?(full_path)
492
+ "/reference/#{File.join(*file_parts)}/"
493
+ else
494
+ folders = file_parts[0..-2]
495
+ filename = file_parts.last
496
+ relative_path = folders.any? ? File.join(*folders, filename) : filename
497
+ "/reference/#{relative_path}"
498
+ end
499
+ end
500
+
501
+ def modules_with_children_for_links
502
+ @modules_with_children_for_links ||= begin
503
+ paths = type_path_lookup.values.map { |p| "Apiwork::#{p}" }.to_set
504
+ paths.select do |path|
505
+ paths.any? { |other| other != path && other.start_with?("#{path}::") }
506
+ end.to_set
507
+ end
508
+ end
509
+
510
+ def render_module(mod, order)
511
+ parts = []
512
+
513
+ parts << <<~FRONTMATTER
514
+ ---
515
+ order: #{order}
516
+ prev: false
517
+ next: false
518
+ ---
519
+ FRONTMATTER
520
+
521
+ parts << "# #{display_title(mod[:path])}\n"
522
+
523
+ if mod[:file] && mod[:line]
524
+ github_link = "#{GITHUB_URL}/#{mod[:file]}#L#{mod[:line]}"
525
+ parts << "[GitHub](#{github_link})\n"
526
+ end
527
+
528
+ parts << "#{linkify_yard_refs(mod[:docstring])}\n" if mod[:docstring].present?
529
+
530
+ render_examples(parts, mod[:examples]) if mod[:examples].any?
531
+
532
+ children = find_direct_children(mod[:path])
533
+ if children.any?
534
+ parts << "## Modules\n"
535
+ render_child_links(parts, mod[:path], children)
536
+ end
537
+
538
+ if mod[:class_methods].any?
539
+ parts << "## Class Methods\n"
540
+ mod[:class_methods].each do |method|
541
+ parts << render_method(method, '.')
542
+ end
543
+ end
544
+
545
+ if mod[:instance_methods].any?
546
+ parts << "## Instance Methods\n"
547
+ mod[:instance_methods].each do |method|
548
+ parts << render_method(method, '#')
549
+ end
550
+ end
551
+
552
+ parts.join("\n")
553
+ end
554
+
555
+ def render_method(method, prefix)
556
+ parts = []
557
+
558
+ parts << "### #{prefix}#{escape_brackets(method[:name])}\n"
559
+ parts << "`#{prefix}#{method[:signature]}`\n"
560
+
561
+ if method[:file] && method[:line]
562
+ github_link = "#{GITHUB_URL}/#{method[:file]}#L#{method[:line]}"
563
+ parts << "[GitHub](#{github_link})\n"
564
+ end
565
+
566
+ parts << "#{linkify_yard_refs(method[:docstring])}\n" if method[:docstring].present?
567
+
568
+ if method[:params].any?
569
+ parts << "**Parameters**\n"
570
+
571
+ parts << '<div class="params-table">'
572
+ parts << ''
573
+ parts << '| Name | Type | Default | Description |'
574
+ parts << '|------|------|---------|-------------|'
575
+
576
+ method[:params].each do |param|
577
+ name_cell = param[:default] ? "`#{param[:name]}`" : "**`#{param[:name]}`**"
578
+ row = [
579
+ name_cell,
580
+ format_param_type(param),
581
+ param[:default] ? "`#{param[:default]}`" : '',
582
+ linkify_yard_refs(param[:description])&.gsub(/\s*\n\s*/, ' ') || '',
583
+ ]
584
+ parts << "| #{row.join(' | ')} |"
585
+ end
586
+ parts << ''
587
+ parts << '</div>'
588
+ parts << ''
589
+ end
590
+
591
+ if method[:returns]
592
+ types = method[:returns][:types].map { |type| linkify_type(type) }.join(', ')
593
+ description = linkify_yard_refs(method[:returns][:description])
594
+ parts << "**Returns**\n"
595
+ parts << (description.blank? ? "#{types}\n" : "#{types} — #{description}\n")
596
+ end
597
+
598
+ if method[:yieldparams].any?
599
+ types = method[:yieldparams].flat_map { |p| p[:types] }.uniq
600
+ linked = types.map { |t| linkify_type(t) }.join(', ')
601
+ parts << "**Yields** #{linked}\n"
602
+ end
603
+
604
+ if method[:see].any?
605
+ linkable_sees = method[:see].select { |ref| see_ref_linkable?(ref) }
606
+ if linkable_sees.any?
607
+ parts << "**See also**\n"
608
+ linkable_sees.each do |ref|
609
+ link_path = yard_ref_to_path(ref)
610
+ parts << "- [#{ref}](#{link_path})"
611
+ end
612
+ parts << ''
613
+ end
614
+ end
615
+
616
+ render_examples(parts, method[:examples]) if method[:examples].any?
617
+
618
+ parts << "---\n"
619
+ parts.join("\n")
620
+ end
621
+ end
622
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ class Registry
5
+ class << self
6
+ def store
7
+ @store ||= {}
8
+ end
9
+
10
+ def find(key)
11
+ store[normalize_key(key)]
12
+ end
13
+
14
+ def find!(key)
15
+ normalized_key = normalize_key(key)
16
+ store.fetch(normalized_key) do
17
+ raise KeyError.new(
18
+ "#{registry_name} :#{normalized_key} not found. Available: #{keys.join(', ')}",
19
+ key: normalized_key,
20
+ receiver: store,
21
+ )
22
+ end
23
+ end
24
+
25
+ def exists?(key)
26
+ store.key?(normalize_key(key))
27
+ end
28
+
29
+ def keys
30
+ store.keys
31
+ end
32
+
33
+ def values
34
+ store.values
35
+ end
36
+
37
+ def delete(key)
38
+ store.delete(normalize_key(key))
39
+ end
40
+
41
+ def clear!
42
+ @store = {}
43
+ end
44
+
45
+ private
46
+
47
+ def normalize_key(key)
48
+ key.to_sym
49
+ end
50
+
51
+ def registry_name
52
+ name.demodulize
53
+ end
54
+ end
55
+ end
56
+ end