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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/CHANGELOG.md +76 -0
- data/README.md +404 -8
- data/bin/console +6 -7
- data/bin/setup +8 -5
- data/exe/graphql-docs +9 -9
- data/graphql-docs.gemspec +50 -38
- data/lib/graphql-docs/app.rb +488 -0
- data/lib/graphql-docs/configuration.rb +28 -11
- data/lib/graphql-docs/generator.rb +147 -42
- data/lib/graphql-docs/helpers.rb +132 -12
- data/lib/graphql-docs/layouts/assets/_sass/_api-box.scss +2 -2
- data/lib/graphql-docs/layouts/assets/_sass/_content.scss +20 -19
- data/lib/graphql-docs/layouts/assets/_sass/_deprecations.scss +7 -0
- data/lib/graphql-docs/layouts/assets/_sass/_fonts.scss +6 -21
- data/lib/graphql-docs/layouts/assets/_sass/_header.scss +8 -2
- data/lib/graphql-docs/layouts/assets/_sass/_mobile.scss +10 -3
- data/lib/graphql-docs/layouts/assets/_sass/_search.scss +32 -10
- data/lib/graphql-docs/layouts/assets/_sass/_sidebar.scss +22 -15
- data/lib/graphql-docs/layouts/assets/_sass/_syntax.scss +1 -1
- data/lib/graphql-docs/layouts/assets/css/screen.scss +54 -5
- data/lib/graphql-docs/layouts/default.html +75 -1
- data/lib/graphql-docs/layouts/includes/sidebar.html +4 -2
- data/lib/graphql-docs/parser.rb +89 -33
- data/lib/graphql-docs/renderer.rb +98 -31
- data/lib/graphql-docs/version.rb +2 -1
- data/lib/graphql-docs.rb +78 -12
- data/lib/tasks/graphql-docs.rake +57 -0
- metadata +74 -78
- data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
- data/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- data/.github/workflows/tests.yml +0 -21
- data/.gitignore +0 -15
- data/.rubocop.yml +0 -11
- data/CODE_OF_CONDUCT.md +0 -84
- data/CONTRIBUTING.md +0 -18
- data/Gemfile +0 -6
- data/Rakefile +0 -86
- data/lib/graphql-docs/layouts/assets/images/graphiql-headers.png +0 -0
- data/lib/graphql-docs/layouts/assets/images/graphiql-variables.png +0 -0
- data/lib/graphql-docs/layouts/assets/images/graphiql.png +0 -0
- data/lib/graphql-docs/layouts/assets/images/search.svg +0 -3
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.eot +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.ttf +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.woff +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_B_0.woff2 +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.eot +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.ttf +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.woff +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_C_0.woff2 +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.eot +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.ttf +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.woff +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_D_0.woff2 +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_E_0.eot +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_E_0.ttf +0 -0
- data/lib/graphql-docs/layouts/assets/webfonts/2C4B9D_E_0.woff +0 -0
- 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(
|
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
|
4
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
-
require
|
|
5
|
+
require "graphql-docs/version"
|
|
6
6
|
|
|
7
7
|
Gem::Specification.new do |spec|
|
|
8
|
-
spec.name
|
|
9
|
-
spec.version
|
|
10
|
-
spec.authors
|
|
11
|
-
spec.email
|
|
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
|
|
14
|
-
spec.description
|
|
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
|
|
22
|
-
spec.license
|
|
23
|
-
spec.metadata
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
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
|
|
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
|
|
33
|
-
spec.executables
|
|
34
|
-
spec.require_paths = [
|
|
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 =
|
|
41
|
+
spec.required_ruby_version = ">= 3.1"
|
|
37
42
|
|
|
38
|
-
spec.add_dependency
|
|
43
|
+
spec.add_dependency "graphql", "~> 2.0"
|
|
39
44
|
|
|
40
45
|
# rendering
|
|
41
|
-
spec.add_dependency
|
|
42
|
-
spec.add_dependency
|
|
43
|
-
spec.add_dependency
|
|
44
|
-
spec.add_dependency
|
|
45
|
-
spec.add_dependency
|
|
46
|
-
spec.add_dependency
|
|
47
|
-
spec.add_dependency
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
spec.add_development_dependency
|
|
55
|
-
spec.add_development_dependency
|
|
56
|
-
spec.add_development_dependency
|
|
57
|
-
spec.add_development_dependency
|
|
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:
|
|
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:
|
|
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
|