graphql-docs 5.2.0 → 6.0.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +76 -0
  4. data/README.md +404 -8
  5. data/bin/console +6 -7
  6. data/bin/setup +8 -5
  7. data/exe/graphql-docs +9 -9
  8. data/graphql-docs.gemspec +50 -38
  9. data/lib/graphql-docs/app.rb +488 -0
  10. data/lib/graphql-docs/configuration.rb +28 -11
  11. data/lib/graphql-docs/generator.rb +147 -42
  12. data/lib/graphql-docs/helpers.rb +132 -12
  13. data/lib/graphql-docs/layouts/assets/_sass/_api-box.scss +2 -2
  14. data/lib/graphql-docs/layouts/assets/_sass/_content.scss +20 -19
  15. data/lib/graphql-docs/layouts/assets/_sass/_deprecations.scss +7 -0
  16. data/lib/graphql-docs/layouts/assets/_sass/_fonts.scss +6 -21
  17. data/lib/graphql-docs/layouts/assets/_sass/_header.scss +8 -2
  18. data/lib/graphql-docs/layouts/assets/_sass/_mobile.scss +10 -3
  19. data/lib/graphql-docs/layouts/assets/_sass/_search.scss +32 -10
  20. data/lib/graphql-docs/layouts/assets/_sass/_sidebar.scss +22 -15
  21. data/lib/graphql-docs/layouts/assets/_sass/_syntax.scss +1 -1
  22. data/lib/graphql-docs/layouts/assets/css/screen.scss +54 -5
  23. data/lib/graphql-docs/layouts/default.html +75 -1
  24. data/lib/graphql-docs/layouts/includes/sidebar.html +4 -2
  25. data/lib/graphql-docs/parser.rb +89 -33
  26. data/lib/graphql-docs/renderer.rb +98 -31
  27. data/lib/graphql-docs/version.rb +2 -1
  28. data/lib/graphql-docs.rb +78 -12
  29. data/lib/tasks/graphql-docs.rake +57 -0
  30. metadata +74 -78
  31. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  32. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  33. data/.github/workflows/tests.yml +0 -21
  34. data/.gitignore +0 -15
  35. data/.rubocop.yml +0 -11
  36. data/CODE_OF_CONDUCT.md +0 -84
  37. data/CONTRIBUTING.md +0 -18
  38. data/Gemfile +0 -6
  39. data/Rakefile +0 -86
  40. data/lib/graphql-docs/layouts/assets/images/graphiql-headers.png +0 -0
  41. data/lib/graphql-docs/layouts/assets/images/graphiql-variables.png +0 -0
  42. data/lib/graphql-docs/layouts/assets/images/graphiql.png +0 -0
  43. data/lib/graphql-docs/layouts/assets/images/search.svg +0 -3
  44. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.eot +0 -0
  45. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.ttf +0 -0
  46. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.woff +0 -0
  47. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.woff2 +0 -0
  48. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.eot +0 -0
  49. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.ttf +0 -0
  50. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.woff +0 -0
  51. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.woff2 +0 -0
  52. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.eot +0 -0
  53. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.ttf +0 -0
  54. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.woff +0 -0
  55. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.woff2 +0 -0
  56. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_E_0.eot +0 -0
  57. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_E_0.ttf +0 -0
  58. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_E_0.woff +0 -0
  59. data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_E_0.woff2 +0 -0
data/graphql-docs.gemspec CHANGED
@@ -1,58 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- lib = File.expand_path('lib', __dir__)
3
+ lib = File.expand_path("lib", __dir__)
4
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
- require 'graphql-docs/version'
5
+ require "graphql-docs/version"
6
6
 
7
7
  Gem::Specification.new do |spec|
8
- spec.name = 'graphql-docs'
9
- spec.version = GraphQLDocs::VERSION
10
- spec.authors = ['Brett Chalupa', 'Garen Torikian']
11
- spec.email = ['brettchalupa@gmail.com']
8
+ spec.name = "graphql-docs"
9
+ spec.version = GraphQLDocs::VERSION
10
+ spec.authors = ["Brett Chalupa", "Garen Torikian"]
11
+ spec.email = ["brettchalupa@gmail.com"]
12
12
 
13
- spec.summary = 'Easily generate beautiful documentation from your GraphQL schema.'
14
- spec.description = <<-EOF
13
+ spec.summary = "Easily generate beautiful documentation from your GraphQL schema."
14
+ spec.description = <<-EOF
15
15
  Library and CLI for generating a website from a GraphQL API's schema
16
16
  definition. With ERB templating support and a plethora of configuration
17
17
  options, you can customize the output to your needs. The library easily
18
18
  integrates with your Ruby deployment toolchain to ensure the docs for your
19
19
  API are up to date.
20
20
  EOF
21
- spec.homepage = 'https://github.com/brettchalupa/graphql-docs'
22
- spec.license = 'MIT'
23
- spec.metadata = {
24
- "bug_tracker_uri" => "https://github.com/brettchalupa/graphql-docs/issues",
25
- "changelog_uri" => "https://github.com/brettchalupa/graphql-docs/blob/main/CHANGELOG.md",
26
- "wiki_uri" => "https://github.com/brettchalupa/graphql-docs/wiki",
21
+ spec.homepage = "https://github.com/brettchalupa/graphql-docs"
22
+ spec.license = "MIT"
23
+ spec.metadata = {
24
+ "homepage_uri" => "https://graphql-docs.bcodes.me",
25
+ "bug_tracker_uri" => "https://github.com/brettchalupa/graphql-docs/issues",
26
+ "changelog_uri" => "https://github.com/brettchalupa/graphql-docs/blob/main/CHANGELOG.md",
27
+ "wiki_uri" => "https://github.com/brettchalupa/graphql-docs/wiki",
28
+ "documentation_uri" => "https://rubydoc.info/github/brettchalupa/graphql-docs.git/main",
29
+ "source_code_uri" => "https://github.com/brettchalupa/graphql-docs"
27
30
  }
28
31
 
29
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
30
- f.match(%r{^(test|spec|features)/})
32
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
33
+ f.match(%r{^(test|spec|features|\.github)/}) ||
34
+ f.match(%r{^(Gemfile|Rakefile|CODE_OF_CONDUCT\.md|CONTRIBUTING\.md|\.gitignore|\.rubocop\.yml|\.standard\.yml|config\.ru)$}) ||
35
+ f.match(%r{/webfonts/}) # Exclude webfonts directory - using system fonts
31
36
  end
32
- spec.bindir = 'exe'
33
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
- spec.require_paths = ['lib']
37
+ spec.bindir = "exe"
38
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
39
+ spec.require_paths = ["lib"]
35
40
 
36
- spec.required_ruby_version = '>= 3.1'
41
+ spec.required_ruby_version = ">= 3.1"
37
42
 
38
- spec.add_dependency 'graphql', '~> 2.0'
43
+ spec.add_dependency "graphql", "~> 2.0"
39
44
 
40
45
  # rendering
41
- spec.add_dependency 'commonmarker', '>= 0.23.6', '~> 0.23'
42
- spec.add_dependency 'escape_utils', '~> 1.2'
43
- spec.add_dependency 'extended-markdown-filter', '~> 0.4'
44
- spec.add_dependency 'gemoji', '~> 3.0'
45
- spec.add_dependency 'html-pipeline', '>= 2.14.3', '~> 2.14'
46
- spec.add_dependency 'sass-embedded', '~> 1.58'
47
- spec.add_dependency 'ostruct', '~> 0.6'
48
- spec.add_dependency 'logger', '~> 1.6'
49
-
50
- spec.add_development_dependency 'html-proofer', '~> 3.4'
51
- spec.add_development_dependency 'minitest', '~> 5.24'
52
- spec.add_development_dependency 'minitest-focus', '~> 1.1'
53
- spec.add_development_dependency 'rake', '~> 13.0'
54
- spec.add_development_dependency 'rubocop', '~> 1.37'
55
- spec.add_development_dependency 'rubocop-performance', '~> 1.15'
56
- spec.add_development_dependency 'webmock', '~> 2.3'
57
- spec.add_development_dependency 'webrick', '~> 1.7'
46
+ spec.add_dependency "commonmarker", "~> 2.0"
47
+ spec.add_dependency "escape_utils", "~> 1.2"
48
+ spec.add_dependency "gemoji", "~> 4.0"
49
+ spec.add_dependency "html-pipeline", "~> 3.0"
50
+ spec.add_dependency "sass-embedded", "~> 1.58"
51
+ spec.add_dependency "ostruct", "~> 0.6"
52
+ spec.add_dependency "logger", "~> 1.6"
53
+
54
+ # rack application support (optional, only needed for GraphQLDocs::App)
55
+ # Users can install rack separately if they want to use the Rack app feature:
56
+ # gem 'rack', '~> 2.0' or gem 'rack', '~> 3.0'
57
+ # The gem works with both Rack 2.x and 3.x
58
+
59
+ spec.add_development_dependency "html-proofer", "~> 5.0"
60
+ spec.add_development_dependency "minitest", "~> 5.24"
61
+ spec.add_development_dependency "minitest-focus", "~> 1.1"
62
+ spec.add_development_dependency "rack", ">= 2.0", "< 4"
63
+ spec.add_development_dependency "rack-test", "~> 2.0"
64
+ spec.add_development_dependency "rackup", "~> 2.0"
65
+ spec.add_development_dependency "rake", "~> 13.0"
66
+ spec.add_development_dependency "standard", "~> 1.0"
67
+ spec.add_development_dependency "webmock", "~> 2.3"
68
+ spec.add_development_dependency "webrick", "~> 1.7"
69
+ spec.add_development_dependency "yard", "~> 0.9"
58
70
  end
@@ -0,0 +1,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ # Lazy-load Rack when the App class is first instantiated
6
+ begin
7
+ require "rack"
8
+ rescue LoadError
9
+ # Define a stub that will raise a better error message
10
+ module Rack
11
+ def self.const_missing(name)
12
+ raise LoadError, "The GraphQLDocs::App feature requires the 'rack' gem. " \
13
+ "Please add it to your Gemfile: gem 'rack', '~> 2.0' or gem 'rack', '~> 3.0'"
14
+ end
15
+ end
16
+ end
17
+
18
+ module GraphQLDocs
19
+ # Rack application for serving GraphQL documentation on-demand.
20
+ #
21
+ # This provides an alternative to the static site generator approach, allowing
22
+ # documentation to be served dynamically from a Rack-compatible web server.
23
+ # Pages are generated on-demand and can be cached for performance.
24
+ #
25
+ # Thread Safety:
26
+ # This application is designed to be thread-safe for use with multi-threaded
27
+ # web servers like Puma or Falcon. Thread safety is achieved through:
28
+ #
29
+ # 1. Immutable core state: @parsed_schema, @base_options, and @renderer are
30
+ # set once during initialization and never mutated.
31
+ # 2. Temporary renderers: When YAML frontmatter is present, a new temporary
32
+ # renderer is created per-request instead of mutating shared state.
33
+ # 3. Cache thread-safety: The @cache hash may require external synchronization
34
+ # in highly concurrent scenarios (consider using a thread-safe cache adapter).
35
+ #
36
+ # @example Standalone usage
37
+ # app = GraphQLDocs::App.new(
38
+ # schema: 'type Query { hello: String }',
39
+ # options: { base_url: '' }
40
+ # )
41
+ # run app
42
+ #
43
+ # @example Mount in Rails
44
+ # mount GraphQLDocs::App.new(schema: MySchema) => '/docs'
45
+ #
46
+ # @example With caching
47
+ # app = GraphQLDocs::App.new(
48
+ # schema: schema_string,
49
+ # options: { cache: true }
50
+ # )
51
+ class App
52
+ include Helpers
53
+
54
+ # @!attribute [r] parsed_schema
55
+ # @return [Hash] The parsed GraphQL schema structure
56
+ attr_reader :parsed_schema
57
+
58
+ # @!attribute [r] options
59
+ # @return [Hash] Configuration options for the app
60
+ attr_reader :options
61
+
62
+ # @!attribute [r] base_options
63
+ # @return [Hash] Base configuration options (immutable)
64
+ attr_reader :base_options
65
+
66
+ # Initializes a new Rack app instance.
67
+ #
68
+ # @param schema [String, GraphQL::Schema] GraphQL schema as IDL string or schema class
69
+ # @param filename [String, nil] Path to GraphQL schema file (alternative to schema param)
70
+ # @param options [Hash] Configuration options
71
+ # @option options [String] :base_url ('') Base URL prefix for all routes
72
+ # @option options [Boolean] :cache (true) Enable page caching
73
+ # @option options [Boolean] :use_default_styles (true) Serve default CSS
74
+ # @option options [Hash] :templates Custom template paths
75
+ # @option options [Class] :renderer Custom renderer class
76
+ #
77
+ # @raise [ArgumentError] If neither schema nor filename is provided
78
+ def initialize(schema: nil, filename: nil, options: {})
79
+ raise ArgumentError, "Must provide either schema or filename" if schema.nil? && filename.nil?
80
+
81
+ @base_options = Configuration::GRAPHQLDOCS_DEFAULTS.merge(options).freeze
82
+ @options = @base_options.dup
83
+
84
+ # Load schema from file if filename provided
85
+ if filename
86
+ raise ArgumentError, "#{filename} does not exist!" unless File.exist?(filename)
87
+ schema = File.read(filename)
88
+ end
89
+
90
+ # Parse schema once at initialization
91
+ parser = Parser.new(schema, @options)
92
+ @parsed_schema = parser.parse
93
+
94
+ @renderer = @options[:renderer].new(@parsed_schema, @options)
95
+
96
+ # Initialize cache
97
+ @cache_enabled = @options.fetch(:cache, true)
98
+ @cache = {} if @cache_enabled
99
+
100
+ # Load templates
101
+ load_templates
102
+
103
+ # Pre-compile assets if using default styles
104
+ compile_assets if @options[:use_default_styles]
105
+ end
106
+
107
+ # Rack interface method.
108
+ #
109
+ # @param env [Hash] Rack environment hash
110
+ # @return [Array] Rack response tuple [status, headers, body]
111
+ def call(env)
112
+ request = Rack::Request.new(env)
113
+ path = clean_path(request.path_info)
114
+
115
+ route(path)
116
+ rescue => e
117
+ [500, {"content-type" => "text/html"}, [error_page(e)]]
118
+ end
119
+
120
+ # Clears the page cache.
121
+ #
122
+ # @return [void]
123
+ def clear_cache!
124
+ @cache&.clear
125
+ end
126
+
127
+ # Reloads the schema and clears cache.
128
+ #
129
+ # @param new_schema [String, GraphQL::Schema] New schema to parse
130
+ # @return [void]
131
+ def reload_schema!(new_schema)
132
+ parser = Parser.new(new_schema, @options)
133
+ @parsed_schema = parser.parse
134
+ @renderer = @options[:renderer].new(@parsed_schema, @options)
135
+ clear_cache!
136
+ end
137
+
138
+ private
139
+
140
+ def clean_path(path)
141
+ # Remove base_url prefix if present
142
+ base = @options[:base_url]
143
+ path = path.sub(/^#{Regexp.escape(base)}/, "") if base && !base.empty?
144
+
145
+ # Normalize path
146
+ path = "/" if path.empty?
147
+ path.sub(/\/$/, "") # Remove trailing slash
148
+ end
149
+
150
+ def route(path)
151
+ case path
152
+ when "", "/", "/index.html", "/index"
153
+ serve_landing_page(:index)
154
+ when "/operation/query", "/operation/query/index.html", "/operation/query/index"
155
+ serve_operation_page
156
+ when "/operation/mutation", "/operation/mutation/index.html", "/operation/mutation/index"
157
+ serve_mutation_operation_page
158
+ when %r{^/object/([^/]+)(?:/index(?:\.html)?)?$}
159
+ serve_type_page(:object, $1)
160
+ when %r{^/query/([^/]+)(?:/index(?:\.html)?)?$}
161
+ serve_type_page(:query, $1)
162
+ when %r{^/mutation/([^/]+)(?:/index(?:\.html)?)?$}
163
+ serve_type_page(:mutation, $1)
164
+ when %r{^/interface/([^/]+)(?:/index(?:\.html)?)?$}
165
+ serve_type_page(:interface, $1)
166
+ when %r{^/enum/([^/]+)(?:/index(?:\.html)?)?$}
167
+ serve_type_page(:enum, $1)
168
+ when %r{^/union/([^/]+)(?:/index(?:\.html)?)?$}
169
+ serve_type_page(:union, $1)
170
+ when %r{^/input_object/([^/]+)(?:/index(?:\.html)?)?$}
171
+ serve_type_page(:input_object, $1)
172
+ when %r{^/scalar/([^/]+)(?:/index(?:\.html)?)?$}
173
+ serve_type_page(:scalar, $1)
174
+ when %r{^/directive/([^/]+)(?:/index(?:\.html)?)?$}
175
+ serve_type_page(:directive, $1)
176
+ when %r{^/assets/(.+)$}
177
+ serve_asset($1)
178
+ else
179
+ [404, {"content-type" => "text/html"}, [not_found_page(path)]]
180
+ end
181
+ end
182
+
183
+ def serve_landing_page(page_type)
184
+ cache_key = "landing:#{page_type}"
185
+
186
+ content = fetch_from_cache(cache_key) do
187
+ generate_landing_page(page_type)
188
+ end
189
+
190
+ return [404, {"content-type" => "text/html"}, ["Landing page not found"]] if content.nil?
191
+
192
+ [200, {"content-type" => "text/html; charset=utf-8"}, [content]]
193
+ end
194
+
195
+ def serve_operation_page
196
+ serve_operation_type_page("query", @query_landing_page, "Query")
197
+ end
198
+
199
+ def serve_mutation_operation_page
200
+ serve_operation_type_page("mutation", @mutation_landing_page, "Mutation")
201
+ end
202
+
203
+ def serve_operation_type_page(operation_name, landing_page, display_name)
204
+ cache_key = "operation:#{operation_name}"
205
+
206
+ content = fetch_from_cache(cache_key) do
207
+ # Find the operation type from the schema
208
+ operation_type = graphql_operation_types.find do |op|
209
+ op[:name] == graphql_root_types[operation_name]
210
+ end
211
+ next nil unless operation_type
212
+
213
+ # Match Generator behavior: extract YAML metadata from landing page if present
214
+ metadata = ""
215
+ if landing_page
216
+ landing_page_content = landing_page.dup
217
+ if yaml?(landing_page_content)
218
+ pieces = yaml_split(landing_page_content)
219
+ pieces[2] = pieces[2].chomp
220
+ metadata = pieces[1, 3].join("\n")
221
+ landing_page_content = pieces[4]
222
+ end
223
+ # Set description like Generator does (thread-safe via dup)
224
+ operation_type = operation_type.dup
225
+ operation_type[:description] = landing_page_content
226
+ end
227
+
228
+ # Generate template content
229
+ opts = @options.merge(type: operation_type).merge(helper_methods)
230
+ contents = @operations_template.result(OpenStruct.new(opts).instance_eval { binding })
231
+
232
+ # Normalize spacing
233
+ contents.gsub!(/^\s+$/, "")
234
+ contents.gsub!(/^\s{4}/m, " ")
235
+
236
+ # Prepend metadata and render
237
+ render_content(metadata + contents, type_category: "operation", type_name: operation_name)
238
+ end
239
+
240
+ return [404, {"content-type" => "text/html"}, ["#{display_name} type not found"]] if content.nil?
241
+
242
+ [200, {"content-type" => "text/html; charset=utf-8"}, [content]]
243
+ end
244
+
245
+ def serve_type_page(type, name)
246
+ name_lower = name.downcase
247
+ cache_key = "#{type}:#{name_lower}"
248
+
249
+ content = fetch_from_cache(cache_key) do
250
+ generate_page_for_type(type, name)
251
+ end
252
+
253
+ return [404, {"content-type" => "text/html"}, ["#{type.capitalize} '#{name}' not found"]] if content.nil?
254
+
255
+ [200, {"content-type" => "text/html; charset=utf-8"}, [content]]
256
+ end
257
+
258
+ def serve_asset(asset_path)
259
+ # Serve compiled CSS
260
+ if asset_path == "style.css" && @compiled_css
261
+ return [200, {"content-type" => "text/css; charset=utf-8"}, [@compiled_css]]
262
+ end
263
+
264
+ # Serve static assets from layouts/assets directory
265
+ asset_file = File.join(File.dirname(__FILE__), "layouts", "assets", asset_path)
266
+
267
+ if File.exist?(asset_file) && File.file?(asset_file)
268
+ content = File.read(asset_file)
269
+ content_type = mime_type_for(asset_path)
270
+ [200, {"content-type" => content_type}, [content]]
271
+ else
272
+ [404, {"content-type" => "text/plain"}, ["Asset not found"]]
273
+ end
274
+ end
275
+
276
+ def generate_landing_page(page_type)
277
+ landing_page_var = instance_variable_get("@#{page_type}_landing_page")
278
+ return nil if landing_page_var.nil?
279
+
280
+ render_content(landing_page_var, type_category: "static", type_name: page_type.to_s)
281
+ end
282
+
283
+ def generate_page_for_type(type, name)
284
+ collection = case type
285
+ when :object then graphql_object_types
286
+ when :query then graphql_query_types
287
+ when :mutation then graphql_mutation_types
288
+ when :interface then graphql_interface_types
289
+ when :enum then graphql_enum_types
290
+ when :union then graphql_union_types
291
+ when :input_object then graphql_input_object_types
292
+ when :scalar then graphql_scalar_types
293
+ when :directive then graphql_directive_types
294
+ else return nil
295
+ end
296
+
297
+ # Find the type (case-insensitive)
298
+ type_data = collection.find { |t| t[:name].downcase == name.downcase }
299
+ return nil unless type_data
300
+
301
+ template_key = case type
302
+ when :object then :objects
303
+ when :query then :queries
304
+ when :mutation then :mutations
305
+ when :interface then :interfaces
306
+ when :enum then :enums
307
+ when :union then :unions
308
+ when :input_object then :input_objects
309
+ when :scalar then :scalars
310
+ when :directive then :directives
311
+ end
312
+
313
+ generate_type_content(template_key, type_data, type.to_s, name)
314
+ end
315
+
316
+ def generate_type_content(template_key, type_data, type_category, type_name)
317
+ template = instance_variable_get("@#{template_key}_template")
318
+ return nil unless template
319
+
320
+ opts = @options.merge(type: type_data).merge(helper_methods)
321
+ contents = template.result(OpenStruct.new(opts).instance_eval { binding })
322
+
323
+ # Normalize spacing
324
+ contents.gsub!(/^\s+$/, "")
325
+ contents.gsub!(/^\s{4}/m, " ")
326
+
327
+ render_content(contents, type_category: type_category, type_name: type_name)
328
+ end
329
+
330
+ # Renders content with optional YAML frontmatter metadata.
331
+ #
332
+ # Thread Safety:
333
+ # This method is designed to be thread-safe for use in multi-threaded Rack servers.
334
+ # It achieves thread-safety by using an immutable options pattern:
335
+ #
336
+ # 1. When YAML frontmatter is present, a NEW temporary renderer is created with
337
+ # merged options, ensuring no mutation of shared @options or @renderer state.
338
+ # 2. When no YAML frontmatter is present, the shared @renderer is used directly
339
+ # (safe because @options and @renderer are immutable after initialization).
340
+ #
341
+ # This approach prevents race conditions where YAML metadata from Request A could
342
+ # leak into Request B when both requests are processed concurrently.
343
+ #
344
+ # @param contents [String] Content to render (may include YAML frontmatter)
345
+ # @param type_category [String] Category of the type being rendered
346
+ # @param type_name [String] Name of the type being rendered
347
+ # @return [String] Rendered HTML content
348
+ #
349
+ # @api private
350
+ def render_content(contents, type_category:, type_name:)
351
+ extra_opts = {}
352
+
353
+ # Parse YAML frontmatter if present
354
+ if yaml?(contents)
355
+ meta, contents = split_into_metadata_and_contents(contents)
356
+ extra_opts = meta
357
+ end
358
+
359
+ # If we have metadata, create a temporary renderer with merged options
360
+ # This ensures thread-safety by not mutating shared @options or @renderer
361
+ if extra_opts.any?
362
+ temp_options = @base_options.merge(extra_opts)
363
+ temp_renderer = @options[:renderer].new(@parsed_schema, temp_options)
364
+ temp_renderer.render(contents, type: type_category, name: type_name, filename: nil)
365
+ else
366
+ @renderer.render(contents, type: type_category, name: type_name, filename: nil)
367
+ end
368
+ end
369
+
370
+ def fetch_from_cache(key)
371
+ return yield unless @cache_enabled
372
+
373
+ if @cache.key?(key)
374
+ @cache[key]
375
+ else
376
+ result = yield
377
+ @cache[key] = result if result
378
+ result
379
+ end
380
+ end
381
+
382
+ def load_templates
383
+ # Load type templates
384
+ %i[operations objects queries mutations interfaces enums unions input_objects scalars directives].each do |sym|
385
+ template_file = @options[:templates][sym]
386
+ next unless File.exist?(template_file)
387
+
388
+ instance_variable_set("@#{sym}_template", ERB.new(File.read(template_file)))
389
+ end
390
+
391
+ # Load landing pages
392
+ %i[index object query mutation interface enum union input_object scalar directive].each do |sym|
393
+ landing_page_file = @options[:landing_pages][sym]
394
+ next if landing_page_file.nil? || !File.exist?(landing_page_file)
395
+
396
+ landing_page_contents = File.read(landing_page_file)
397
+ metadata = ""
398
+
399
+ if File.extname(landing_page_file) == ".erb"
400
+ opts = @options.merge(@options[:landing_pages][:variables]).merge(helper_methods)
401
+ if yaml?(landing_page_contents)
402
+ metadata, landing_page = split_into_metadata_and_contents(landing_page_contents, parse: false)
403
+ erb_template = ERB.new(landing_page)
404
+ else
405
+ erb_template = ERB.new(landing_page_contents)
406
+ end
407
+ landing_page_contents = erb_template.result(OpenStruct.new(opts).instance_eval { binding })
408
+ end
409
+
410
+ instance_variable_set("@#{sym}_landing_page", metadata + landing_page_contents)
411
+ end
412
+ end
413
+
414
+ def compile_assets
415
+ return unless @options[:use_default_styles]
416
+
417
+ assets_dir = File.join(File.dirname(__FILE__), "layouts", "assets")
418
+ scss_file = File.join(assets_dir, "css", "screen.scss")
419
+
420
+ if File.exist?(scss_file)
421
+ require "sass-embedded"
422
+ @compiled_css = Sass.compile(scss_file).css
423
+ end
424
+ end
425
+
426
+ def mime_type_for(path)
427
+ ext = File.extname(path).downcase
428
+ case ext
429
+ when ".css" then "text/css"
430
+ when ".js" then "application/javascript"
431
+ when ".png" then "image/png"
432
+ when ".jpg", ".jpeg" then "image/jpeg"
433
+ when ".gif" then "image/gif"
434
+ when ".svg" then "image/svg+xml"
435
+ when ".woff" then "font/woff"
436
+ when ".woff2" then "font/woff2"
437
+ when ".ttf" then "font/ttf"
438
+ when ".eot" then "application/vnd.ms-fontobject"
439
+ else "application/octet-stream"
440
+ end
441
+ end
442
+
443
+ def not_found_page(path)
444
+ <<~HTML
445
+ <!DOCTYPE html>
446
+ <html>
447
+ <head>
448
+ <title>404 Not Found</title>
449
+ <style>
450
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; padding: 40px; max-width: 600px; margin: 0 auto; }
451
+ h1 { color: #de4f4f; }
452
+ code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }
453
+ </style>
454
+ </head>
455
+ <body>
456
+ <h1>404 Not Found</h1>
457
+ <p>The requested path <code>#{Rack::Utils.escape_html(path)}</code> was not found.</p>
458
+ <p><a href="#{@options[:base_url]}/">← Back to documentation home</a></p>
459
+ </body>
460
+ </html>
461
+ HTML
462
+ end
463
+
464
+ def error_page(error)
465
+ <<~HTML
466
+ <!DOCTYPE html>
467
+ <html>
468
+ <head>
469
+ <title>500 Internal Server Error</title>
470
+ <style>
471
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; padding: 40px; max-width: 800px; margin: 0 auto; }
472
+ h1 { color: #de4f4f; }
473
+ pre { background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }
474
+ </style>
475
+ </head>
476
+ <body>
477
+ <h1>500 Internal Server Error</h1>
478
+ <p>An error occurred while generating the documentation page.</p>
479
+ <h2>Error Details:</h2>
480
+ <pre>#{Rack::Utils.escape_html(error.class.name)}: #{Rack::Utils.escape_html(error.message)}
481
+
482
+ #{Rack::Utils.escape_html(error.backtrace.first(10).join("\n"))}</pre>
483
+ </body>
484
+ </html>
485
+ HTML
486
+ end
487
+ end
488
+ end
@@ -1,7 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GraphQLDocs
4
+ # Configuration module that defines default options for GraphQLDocs.
5
+ #
6
+ # All configuration options can be overridden when calling {GraphQLDocs.build}.
7
+ #
8
+ # @see GraphQLDocs.build
4
9
  module Configuration
10
+ # Default configuration options for GraphQLDocs.
11
+ #
12
+ # @return [Hash] Hash of default configuration values
13
+ #
14
+ # @option defaults [String] :filename (nil) Path to GraphQL schema IDL file
15
+ # @option defaults [String, GraphQL::Schema] :schema (nil) GraphQL schema as string or class
16
+ # @option defaults [Boolean] :delete_output (false) Delete output directory before generating
17
+ # @option defaults [String] :output_dir ('./output/') Directory for generated HTML files
18
+ # @option defaults [Hash] :pipeline_config Configuration for html-pipeline rendering
19
+ # @option defaults [Class] :renderer (GraphQLDocs::Renderer) Renderer class to use
20
+ # @option defaults [Boolean] :use_default_styles (true) Include default CSS styles
21
+ # @option defaults [String] :base_url ('') Base URL to prepend to assets and links
22
+ # @option defaults [Hash] :templates Paths to ERB template files for different GraphQL types
23
+ # @option defaults [Hash] :landing_pages Paths to landing page files for each type
24
+ # @option defaults [Hash] :classes Additional CSS class names for styling elements
5
25
  GRAPHQLDOCS_DEFAULTS = {
6
26
  # initialize
7
27
  filename: nil,
@@ -9,21 +29,18 @@ module GraphQLDocs
9
29
 
10
30
  # Generating
11
31
  delete_output: false,
12
- output_dir: './output/',
32
+ output_dir: "./output/",
13
33
  pipeline_config: {
14
- pipeline:
15
- %i[ExtendedMarkdownFilter
16
- EmojiFilter
17
- TableOfContentsFilter],
34
+ pipeline: [], # html-pipeline 3 filters are instantiated differently
18
35
  context: {
19
36
  gfm: false,
20
37
  unsafe: true, # necessary for layout needs, given that it's all HTML templates
21
- asset_root: 'https://a248.e.akamai.net/assets.github.com/images/icons'
38
+ asset_root: "https://a248.e.akamai.net/assets.github.com/images/icons"
22
39
  }
23
40
  },
24
41
  renderer: GraphQLDocs::Renderer,
25
42
  use_default_styles: true,
26
- base_url: '',
43
+ base_url: "",
27
44
 
28
45
  templates: {
29
46
  default: "#{File.dirname(__FILE__)}/layouts/default.html",
@@ -58,10 +75,10 @@ module GraphQLDocs
58
75
  },
59
76
 
60
77
  classes: {
61
- field_entry: '',
62
- deprecation_notice: '',
63
- notice: '',
64
- notice_title: ''
78
+ field_entry: "",
79
+ deprecation_notice: "",
80
+ notice: "",
81
+ notice_title: ""
65
82
  }
66
83
  }.freeze
67
84
  end