diff --git a/README.md b/README.md index f367df48..283e6b78 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ gem install graphql-docs ## Usage +GraphQLDocs provides two ways to serve your documentation: + +1. **Static Site Generator (SSG)** - Pre-generate all HTML files (default) +2. **Rack Application** - Serve documentation dynamically on-demand + +### Static Site Generation (SSG) + GraphQLDocs can be used as a Ruby library to build the documentation website. Using it as a Ruby library allows for more control and using every supported option. Here's an example: ```ruby @@ -55,6 +62,110 @@ See all of the supported CLI options with: graphql-docs -h ``` +### Rack Application (Dynamic) + +For more flexibility and control, you can serve documentation dynamically using the Rack application. This is useful for: + +- Internal tools with frequently changing schemas +- Integration with existing Rails/Sinatra applications +- Adding authentication/authorization middleware +- Dynamic schema loading from databases or APIs + +**Requirements**: The Rack application feature requires the `rack` gem (version 2.x or 3.x). Add it to your Gemfile: + +```ruby +gem 'rack', '~> 3.0' # or '~> 2.0' for Rack 2.x +``` + +The gem is compatible with both Rack 2.x and 3.x, so you can use whichever version your application requires. + +#### Standalone Rack App + +Create a `config.ru` file: + +```ruby +require 'graphql-docs' + +schema = File.read('schema.graphql') + +app = GraphQLDocs::App.new( + schema: schema, + options: { + base_url: '', + use_default_styles: true, + cache: true # Enable page caching + } +) + +run app +``` + +Then run with: + +```console +rackup config.ru +``` + +Visit `http://localhost:9292` to view your docs. + +#### Mounting in Rails + +```ruby +# config/routes.rb +require 'graphql-docs' + +Rails.application.routes.draw do + mount GraphQLDocs::App.new(schema: MyGraphQLSchema) => '/docs' +end +``` + +#### Mounting in Sinatra + +```ruby +require 'sinatra' +require 'graphql-docs' + +schema = File.read('schema.graphql') +docs_app = GraphQLDocs::App.new(schema: schema) + +map '/docs' do + run docs_app +end + +map '/' do + run Sinatra::Application +end +``` + +#### Rack App Features + +- **On-demand generation** - Pages are generated when requested +- **Built-in caching** - Generated pages are cached in memory (disable with `cache: false`) +- **Schema reloading** - Update schema without restarting server: + +```ruby +app = GraphQLDocs::App.new(schema: schema) + +# Later, reload with new schema +new_schema = File.read('updated_schema.graphql') +app.reload_schema!(new_schema) +``` + +- **Asset serving** - CSS, fonts, and images served automatically +- **Error handling** - Friendly error pages for missing types + +#### SSG vs Rack Comparison + +| Feature | SSG | Rack App | +|---------|-----|----------| +| Setup complexity | Low | Medium | +| First page load | Instant | Fast (with caching) | +| Schema updates | Manual rebuild | Automatic/reload | +| Hosting | Any static host | Requires Ruby server | +| Memory usage | Minimal | Higher (cached pages) | +| Authentication | Separate layer | Built-in middleware | +| Best for | Public docs, open source | Internal tools, dynamic schemas | + ## Breakdown There are several phases going on the single `GraphQLDocs.build` call: @@ -398,20 +509,42 @@ an interactive prompt that will allow you to experiment. ## Sample Site -Clone this repository and run: +Clone this repository and try out both modes: -``` +### Static Site Generation + +Generate the sample documentation to the `output` directory: + +```console bin/rake sample:generate ``` -to see some sample output in the `output` dir. +Then boot up a server to view the pre-generated files: + +```console +bin/rake sample:serve +``` + +Visit `http://localhost:5050` to view the static documentation. -Boot up a server to view it: +### Rack Application (Dynamic) +Run the sample docs as a dynamic Rack application: + +```console +bin/rake sample:rack ``` -bin/rake sample:serve + +Or use the config.ru directly: + +```console +rackup config.ru ``` +Visit `http://localhost:9292` to view the documentation served dynamically. + +**Key Difference**: The SSG version pre-generates all pages (faster initial load, no runtime cost), while the Rack version generates pages on-demand (better for dynamic schemas, easier integration). + ## Credits Originally built by [gjtorikian](https://github.com/gjtorikian). Actively maintained by [brettchalupa](https://github.com/brettchalupa). diff --git a/Rakefile b/Rakefile index 11a728fd..00e322af 100644 --- a/Rakefile +++ b/Rakefile @@ -83,4 +83,34 @@ namespace :sample do server.start end task server: :serve + + desc 'Run the sample docs as a Rack application (dynamic, on-demand generation)' + task :rack do + require 'rack' + require 'graphql-docs' + + schema_path = File.join(File.dirname(__FILE__), 'test', 'graphql-docs', 'fixtures', 'gh-schema.graphql') + schema = File.read(schema_path) + + app = GraphQLDocs::App.new( + schema: schema, + options: { + base_url: '', + use_default_styles: true, + cache: true + } + ) + + PORT = ENV.fetch('PORT', '9292') + puts "Starting Rack server in dynamic mode (on-demand generation)" + puts "Navigate to http://localhost:#{PORT} to view the sample docs" + puts "Press Ctrl+C to stop" + puts "" + puts "NOTE: This serves documentation dynamically - pages are generated on request" + puts " Compare with 'rake sample:serve' which serves pre-generated static files" + puts "" + + # Use rackup for Rack 3.x compatibility + sh "rackup config.ru -p #{PORT}" + end end diff --git a/config.ru b/config.ru new file mode 100644 index 00000000..12877bea --- /dev/null +++ b/config.ru @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Rack configuration file for running GraphQL Docs as a web server +# +# This demonstrates using GraphQLDocs as a Rack application that serves +# documentation dynamically on-demand instead of pre-generating static files. +# +# Run with: rackup config.ru +# Or with specific port: rackup config.ru -p 9292 + +require_relative 'lib/graphql-docs' + +# Load the sample GraphQL schema +schema_path = File.join(__dir__, 'test', 'graphql-docs', 'fixtures', 'gh-schema.graphql') + +unless File.exist?(schema_path) + puts "Error: Sample schema not found at #{schema_path}" + puts "Please ensure the schema file exists before starting the server." + exit 1 +end + +schema = File.read(schema_path) + +# Create the Rack app +app = GraphQLDocs::App.new( + schema: schema, + options: { + base_url: '', + use_default_styles: true, + cache: true + } +) + +# Log requests in development +use Rack::CommonLogger + +# Add reloader for development (optional, requires 'rack' gem) +if ENV['RACK_ENV'] != 'production' + puts "Running in development mode" + puts "Visit http://localhost:9292 to view the documentation" + puts "Press Ctrl+C to stop the server" +end + +run app diff --git a/graphql-docs.gemspec b/graphql-docs.gemspec index 7eedf425..20908819 100644 --- a/graphql-docs.gemspec +++ b/graphql-docs.gemspec @@ -50,9 +50,17 @@ Gem::Specification.new do |spec| spec.add_dependency 'ostruct', '~> 0.6' spec.add_dependency 'logger', '~> 1.6' + # rack application support (optional, only needed for GraphQLDocs::App) + # Users can install rack separately if they want to use the Rack app feature: + # gem 'rack', '~> 2.0' or gem 'rack', '~> 3.0' + # The gem works with both Rack 2.x and 3.x + spec.add_development_dependency 'html-proofer', '~> 3.4' spec.add_development_dependency 'minitest', '~> 5.24' spec.add_development_dependency 'minitest-focus', '~> 1.1' + spec.add_development_dependency 'rack', '>= 2.0', '< 4' + spec.add_development_dependency 'rack-test', '~> 2.0' + spec.add_development_dependency 'rackup', '~> 2.0' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rubocop', '~> 1.37' spec.add_development_dependency 'rubocop-performance', '~> 1.15' diff --git a/lib/graphql-docs.rb b/lib/graphql-docs.rb index 3dfcf5f4..320c04e9 100644 --- a/lib/graphql-docs.rb +++ b/lib/graphql-docs.rb @@ -7,6 +7,13 @@ require 'graphql-docs/parser' require 'graphql-docs/version' +# Lazy-load the Rack app - only loads if Rack is available +begin + require 'graphql-docs/app' if defined?(Rack) +rescue LoadError + # Rack not available, App class won't be loaded +end + # GraphQLDocs is a library for generating beautiful HTML documentation from GraphQL schemas. # It parses GraphQL schema files or schema objects and generates a complete documentation website # with customizable templates and styling. diff --git a/lib/graphql-docs/app.rb b/lib/graphql-docs/app.rb new file mode 100644 index 00000000..1bfd6290 --- /dev/null +++ b/lib/graphql-docs/app.rb @@ -0,0 +1,488 @@ +# frozen_string_literal: true + +require 'erb' + +# Lazy-load Rack when the App class is first instantiated +begin + require 'rack' +rescue LoadError => e + # Define a stub that will raise a better error message + module Rack + def self.const_missing(name) + raise LoadError, "The GraphQLDocs::App feature requires the 'rack' gem. " \ + "Please add it to your Gemfile: gem 'rack', '~> 2.0' or gem 'rack', '~> 3.0'" + end + end +end + +module GraphQLDocs + # Rack application for serving GraphQL documentation on-demand. + # + # This provides an alternative to the static site generator approach, allowing + # documentation to be served dynamically from a Rack-compatible web server. + # Pages are generated on-demand and can be cached for performance. + # + # Thread Safety: + # This application is designed to be thread-safe for use with multi-threaded + # web servers like Puma or Falcon. Thread safety is achieved through: + # + # 1. Immutable core state: @parsed_schema, @base_options, and @renderer are + # set once during initialization and never mutated. + # 2. Temporary renderers: When YAML frontmatter is present, a new temporary + # renderer is created per-request instead of mutating shared state. + # 3. Cache thread-safety: The @cache hash may require external synchronization + # in highly concurrent scenarios (consider using a thread-safe cache adapter). + # + # @example Standalone usage + # app = GraphQLDocs::App.new( + # schema: 'type Query { hello: String }', + # options: { base_url: '' } + # ) + # run app + # + # @example Mount in Rails + # mount GraphQLDocs::App.new(schema: MySchema) => '/docs' + # + # @example With caching + # app = GraphQLDocs::App.new( + # schema: schema_string, + # options: { cache: true } + # ) + class App + include Helpers + + # @!attribute [r] parsed_schema + # @return [Hash] The parsed GraphQL schema structure + attr_reader :parsed_schema + + # @!attribute [r] options + # @return [Hash] Configuration options for the app + attr_reader :options + + # @!attribute [r] base_options + # @return [Hash] Base configuration options (immutable) + attr_reader :base_options + + # Initializes a new Rack app instance. + # + # @param schema [String, GraphQL::Schema] GraphQL schema as IDL string or schema class + # @param filename [String, nil] Path to GraphQL schema file (alternative to schema param) + # @param options [Hash] Configuration options + # @option options [String] :base_url ('') Base URL prefix for all routes + # @option options [Boolean] :cache (true) Enable page caching + # @option options [Boolean] :use_default_styles (true) Serve default CSS + # @option options [Hash] :templates Custom template paths + # @option options [Class] :renderer Custom renderer class + # + # @raise [ArgumentError] If neither schema nor filename is provided + def initialize(schema: nil, filename: nil, options: {}) + raise ArgumentError, 'Must provide either schema or filename' if schema.nil? && filename.nil? + + @base_options = Configuration::GRAPHQLDOCS_DEFAULTS.merge(options).freeze + @options = @base_options.dup + + # Load schema from file if filename provided + if filename + raise ArgumentError, "#{filename} does not exist!" unless File.exist?(filename) + schema = File.read(filename) + end + + # Parse schema once at initialization + parser = Parser.new(schema, @options) + @parsed_schema = parser.parse + + @renderer = @options[:renderer].new(@parsed_schema, @options) + + # Initialize cache + @cache_enabled = @options.fetch(:cache, true) + @cache = {} if @cache_enabled + + # Load templates + load_templates + + # Pre-compile assets if using default styles + compile_assets if @options[:use_default_styles] + end + + # Rack interface method. + # + # @param env [Hash] Rack environment hash + # @return [Array] Rack response tuple [status, headers, body] + def call(env) + request = Rack::Request.new(env) + path = clean_path(request.path_info) + + route(path) + rescue StandardError => e + [500, { 'content-type' => 'text/html' }, [error_page(e)]] + end + + # Clears the page cache. + # + # @return [void] + def clear_cache! + @cache&.clear + end + + # Reloads the schema and clears cache. + # + # @param new_schema [String, GraphQL::Schema] New schema to parse + # @return [void] + def reload_schema!(new_schema) + parser = Parser.new(new_schema, @options) + @parsed_schema = parser.parse + @renderer = @options[:renderer].new(@parsed_schema, @options) + clear_cache! + end + + private + + def clean_path(path) + # Remove base_url prefix if present + base = @options[:base_url] + path = path.sub(/^#{Regexp.escape(base)}/, '') if base && !base.empty? + + # Normalize path + path = '/' if path.empty? + path.sub(/\/$/, '') # Remove trailing slash + end + + def route(path) + case path + when '', '/', '/index.html', '/index' + serve_landing_page(:index) + when '/operation/query', '/operation/query/index.html', '/operation/query/index' + serve_operation_page + when '/operation/mutation', '/operation/mutation/index.html', '/operation/mutation/index' + serve_mutation_operation_page + when %r{^/object/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:object, $1) + when %r{^/query/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:query, $1) + when %r{^/mutation/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:mutation, $1) + when %r{^/interface/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:interface, $1) + when %r{^/enum/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:enum, $1) + when %r{^/union/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:union, $1) + when %r{^/input_object/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:input_object, $1) + when %r{^/scalar/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:scalar, $1) + when %r{^/directive/([^/]+)(?:/index(?:\.html)?)?$} + serve_type_page(:directive, $1) + when %r{^/assets/(.+)$} + serve_asset($1) + else + [404, { 'content-type' => 'text/html' }, [not_found_page(path)]] + end + end + + def serve_landing_page(page_type) + cache_key = "landing:#{page_type}" + + content = fetch_from_cache(cache_key) do + generate_landing_page(page_type) + end + + return [404, { 'content-type' => 'text/html' }, ['Landing page not found']] if content.nil? + + [200, { 'content-type' => 'text/html; charset=utf-8' }, [content]] + end + + def serve_operation_page + serve_operation_type_page('query', @query_landing_page, 'Query') + end + + def serve_mutation_operation_page + serve_operation_type_page('mutation', @mutation_landing_page, 'Mutation') + end + + def serve_operation_type_page(operation_name, landing_page, display_name) + cache_key = "operation:#{operation_name}" + + content = fetch_from_cache(cache_key) do + # Find the operation type from the schema + operation_type = graphql_operation_types.find do |op| + op[:name] == graphql_root_types[operation_name] + end + next nil unless operation_type + + # Match Generator behavior: extract YAML metadata from landing page if present + metadata = '' + if landing_page + landing_page_content = landing_page.dup + if yaml?(landing_page_content) + pieces = yaml_split(landing_page_content) + pieces[2] = pieces[2].chomp + metadata = pieces[1, 3].join("\n") + landing_page_content = pieces[4] + end + # Set description like Generator does (thread-safe via dup) + operation_type = operation_type.dup + operation_type[:description] = landing_page_content + end + + # Generate template content + opts = @options.merge(type: operation_type).merge(helper_methods) + contents = @operations_template.result(OpenStruct.new(opts).instance_eval { binding }) + + # Normalize spacing + contents.gsub!(/^\s+$/, '') + contents.gsub!(/^\s{4}/m, ' ') + + # Prepend metadata and render + render_content(metadata + contents, type_category: 'operation', type_name: operation_name) + end + + return [404, { 'content-type' => 'text/html' }, ["#{display_name} type not found"]] if content.nil? + + [200, { 'content-type' => 'text/html; charset=utf-8' }, [content]] + end + + def serve_type_page(type, name) + name_lower = name.downcase + cache_key = "#{type}:#{name_lower}" + + content = fetch_from_cache(cache_key) do + generate_page_for_type(type, name) + end + + return [404, { 'content-type' => 'text/html' }, ["#{type.capitalize} '#{name}' not found"]] if content.nil? + + [200, { 'content-type' => 'text/html; charset=utf-8' }, [content]] + end + + def serve_asset(asset_path) + # Serve compiled CSS + if asset_path == 'style.css' && @compiled_css + return [200, { 'content-type' => 'text/css; charset=utf-8' }, [@compiled_css]] + end + + # Serve static assets from layouts/assets directory + asset_file = File.join(File.dirname(__FILE__), 'layouts', 'assets', asset_path) + + if File.exist?(asset_file) && File.file?(asset_file) + content = File.read(asset_file) + content_type = mime_type_for(asset_path) + [200, { 'content-type' => content_type }, [content]] + else + [404, { 'content-type' => 'text/plain' }, ['Asset not found']] + end + end + + def generate_landing_page(page_type) + landing_page_var = instance_variable_get("@#{page_type}_landing_page") + return nil if landing_page_var.nil? + + render_content(landing_page_var, type_category: 'static', type_name: page_type.to_s) + end + + def generate_page_for_type(type, name) + collection = case type + when :object then graphql_object_types + when :query then graphql_query_types + when :mutation then graphql_mutation_types + when :interface then graphql_interface_types + when :enum then graphql_enum_types + when :union then graphql_union_types + when :input_object then graphql_input_object_types + when :scalar then graphql_scalar_types + when :directive then graphql_directive_types + else return nil + end + + # Find the type (case-insensitive) + type_data = collection.find { |t| t[:name].downcase == name.downcase } + return nil unless type_data + + template_key = case type + when :object then :objects + when :query then :queries + when :mutation then :mutations + when :interface then :interfaces + when :enum then :enums + when :union then :unions + when :input_object then :input_objects + when :scalar then :scalars + when :directive then :directives + end + + generate_type_content(template_key, type_data, type.to_s, name) + end + + def generate_type_content(template_key, type_data, type_category, type_name) + template = instance_variable_get("@#{template_key}_template") + return nil unless template + + opts = @options.merge(type: type_data).merge(helper_methods) + contents = template.result(OpenStruct.new(opts).instance_eval { binding }) + + # Normalize spacing + contents.gsub!(/^\s+$/, '') + contents.gsub!(/^\s{4}/m, ' ') + + render_content(contents, type_category: type_category, type_name: type_name) + end + + # Renders content with optional YAML frontmatter metadata. + # + # Thread Safety: + # This method is designed to be thread-safe for use in multi-threaded Rack servers. + # It achieves thread-safety by using an immutable options pattern: + # + # 1. When YAML frontmatter is present, a NEW temporary renderer is created with + # merged options, ensuring no mutation of shared @options or @renderer state. + # 2. When no YAML frontmatter is present, the shared @renderer is used directly + # (safe because @options and @renderer are immutable after initialization). + # + # This approach prevents race conditions where YAML metadata from Request A could + # leak into Request B when both requests are processed concurrently. + # + # @param contents [String] Content to render (may include YAML frontmatter) + # @param type_category [String] Category of the type being rendered + # @param type_name [String] Name of the type being rendered + # @return [String] Rendered HTML content + # + # @api private + def render_content(contents, type_category:, type_name:) + extra_opts = {} + + # Parse YAML frontmatter if present + if yaml?(contents) + meta, contents = split_into_metadata_and_contents(contents) + extra_opts = meta + end + + # If we have metadata, create a temporary renderer with merged options + # This ensures thread-safety by not mutating shared @options or @renderer + if extra_opts.any? + temp_options = @base_options.merge(extra_opts) + temp_renderer = @options[:renderer].new(@parsed_schema, temp_options) + temp_renderer.render(contents, type: type_category, name: type_name, filename: nil) + else + @renderer.render(contents, type: type_category, name: type_name, filename: nil) + end + end + + def fetch_from_cache(key) + return yield unless @cache_enabled + + if @cache.key?(key) + @cache[key] + else + result = yield + @cache[key] = result if result + result + end + end + + def load_templates + # Load type templates + %i[operations objects queries mutations interfaces enums unions input_objects scalars directives].each do |sym| + template_file = @options[:templates][sym] + next unless File.exist?(template_file) + + instance_variable_set("@#{sym}_template", ERB.new(File.read(template_file))) + end + + # Load landing pages + %i[index object query mutation interface enum union input_object scalar directive].each do |sym| + landing_page_file = @options[:landing_pages][sym] + next if landing_page_file.nil? || !File.exist?(landing_page_file) + + landing_page_contents = File.read(landing_page_file) + metadata = '' + + if File.extname(landing_page_file) == '.erb' + opts = @options.merge(@options[:landing_pages][:variables]).merge(helper_methods) + if yaml?(landing_page_contents) + metadata, landing_page = split_into_metadata_and_contents(landing_page_contents, parse: false) + erb_template = ERB.new(landing_page) + else + erb_template = ERB.new(landing_page_contents) + end + landing_page_contents = erb_template.result(OpenStruct.new(opts).instance_eval { binding }) + end + + instance_variable_set("@#{sym}_landing_page", metadata + landing_page_contents) + end + end + + def compile_assets + return unless @options[:use_default_styles] + + assets_dir = File.join(File.dirname(__FILE__), 'layouts', 'assets') + scss_file = File.join(assets_dir, 'css', 'screen.scss') + + if File.exist?(scss_file) + require 'sass-embedded' + @compiled_css = Sass.compile(scss_file).css + end + end + + def mime_type_for(path) + ext = File.extname(path).downcase + case ext + when '.css' then 'text/css' + when '.js' then 'application/javascript' + when '.png' then 'image/png' + when '.jpg', '.jpeg' then 'image/jpeg' + when '.gif' then 'image/gif' + when '.svg' then 'image/svg+xml' + when '.woff' then 'font/woff' + when '.woff2' then 'font/woff2' + when '.ttf' then 'font/ttf' + when '.eot' then 'application/vnd.ms-fontobject' + else 'application/octet-stream' + end + end + + def not_found_page(path) + <<~HTML + + +
+The requested path #{Rack::Utils.escape_html(path)} was not found.
An error occurred while generating the documentation page.
+#{Rack::Utils.escape_html(error.class.name)}: #{Rack::Utils.escape_html(error.message)}
+
+#{Rack::Utils.escape_html(error.backtrace.first(10).join("\n"))}
+
+
+ HTML
+ end
+ end
+end
diff --git a/lib/graphql-docs/generator.rb b/lib/graphql-docs/generator.rb
index e98f19ef..1a2ec1ed 100644
--- a/lib/graphql-docs/generator.rb
+++ b/lib/graphql-docs/generator.rb
@@ -289,10 +289,10 @@ def write_file(type, name, contents, trim: true)
FileUtils.mkdir_p(path)
end
+ metadata = {}
if yaml?(contents)
# Split data
- meta, contents = split_into_metadata_and_contents(contents)
- @options = @options.merge(meta)
+ metadata, contents = split_into_metadata_and_contents(contents)
end
if trim
@@ -302,8 +302,46 @@ def write_file(type, name, contents, trim: true)
end
filename = File.join(path, 'index.html')
- contents = @renderer.render(contents, type: type, name: name, filename: filename)
+ contents = render_with_metadata(contents, metadata, type: type, name: name, filename: filename)
File.write(filename, contents) unless contents.nil?
end
+
+ # Renders content with optional metadata, without polluting @options.
+ #
+ # File Isolation:
+ # This method ensures that YAML frontmatter metadata from one file doesn't
+ # pollute or leak into other files during batch generation. It achieves this
+ # through an immutable options pattern:
+ #
+ # 1. When metadata is present (from YAML frontmatter), a NEW temporary renderer
+ # is created with merged options, keeping @options and @renderer unchanged.
+ # 2. When no metadata is present, the shared @renderer is used directly
+ # (safe because @options and @renderer remain unchanged throughout generation).
+ #
+ # This prevents a bug where title or other metadata from file1.md would
+ # persist and incorrectly appear in file2.md if they were processed sequentially.
+ #
+ # Example:
+ # # file1.md has "title: Custom Title"
+ # # file2.md has no YAML frontmatter
+ # # Without this isolation, file2.md would incorrectly get "Custom Title"
+ #
+ # @param contents [String] Content to render
+ # @param metadata [Hash] Metadata extracted from YAML frontmatter
+ # @param type [String] Type category
+ # @param name [String] Type name
+ # @param filename [String] Output filename
+ # @return [String, nil] Rendered HTML content
+ #
+ # @api private
+ def render_with_metadata(contents, metadata, type:, name:, filename:)
+ if metadata.is_a?(Hash) && metadata.any?
+ temp_options = @options.merge(metadata)
+ temp_renderer = @options[:renderer].new(@parsed_schema, temp_options)
+ temp_renderer.render(contents, type: type, name: name, filename: filename)
+ else
+ @renderer.render(contents, type: type, name: name, filename: filename)
+ end
+ end
end
end
diff --git a/lib/graphql-docs/helpers.rb b/lib/graphql-docs/helpers.rb
index 5fc79bc4..d6aff717 100644
--- a/lib/graphql-docs/helpers.rb
+++ b/lib/graphql-docs/helpers.rb
@@ -19,7 +19,7 @@ module Helpers
SLUGIFY_PRETTY_REGEXP = Regexp.new("[^[:alnum:]._~!$&'()+,;=@]+").freeze
# @!attribute [rw] templates
- # @return [Hash] Cache of loaded ERB templates
+ # @return [Hash] Cache of loaded ERB templates for includes
attr_accessor :templates
# Converts a string into a URL-friendly slug.
@@ -153,6 +153,7 @@ def graphql_directive_types
#
# @raise [RuntimeError] If YAML front matter format is invalid
# @raise [RuntimeError] If YAML parsing fails
+ # @raise [TypeError] If parsed YAML is not a Hash
def split_into_metadata_and_contents(contents, parse: true)
pieces = yaml_split(contents)
raise "The file '#{content_filename}' appears to start with a metadata section (three or five dashes at the top) but it does not seem to be in the correct format." if pieces.size < 4
@@ -167,6 +168,12 @@ def split_into_metadata_and_contents(contents, parse: true)
rescue Exception => e # rubocop:disable Lint/RescueException
raise "Could not parse YAML for #{name}: #{e.message}"
end
+
+ # Validate that parsed YAML is a Hash when parsing is enabled
+ if parse && !meta.is_a?(Hash)
+ raise TypeError, "Expected YAML front matter to be a hash, got #{meta.class}"
+ end
+
[meta, pieces[4]]
end
diff --git a/lib/graphql-docs/layouts/assets/_sass/_sidebar.scss b/lib/graphql-docs/layouts/assets/_sass/_sidebar.scss
index a2d1821c..acd5ca61 100644
--- a/lib/graphql-docs/layouts/assets/_sass/_sidebar.scss
+++ b/lib/graphql-docs/layouts/assets/_sass/_sidebar.scss
@@ -78,11 +78,12 @@
padding: 0.01em 16px;
margin-bottom: 20px;
- img {
+ svg {
position: absolute;
left: 10px;
height: 16px;
width: 16px;
+ fill: currentColor;
}
input {
@@ -104,7 +105,7 @@
}
@media (prefers-color-scheme: dark) {
- #sidebar #search img {
+ #sidebar #search svg {
filter: brightness(0) invert(1);
}
}
diff --git a/lib/graphql-docs/layouts/assets/images/search.svg b/lib/graphql-docs/layouts/assets/images/search.svg
deleted file mode 100644
index eee8ff56..00000000
--- a/lib/graphql-docs/layouts/assets/images/search.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/lib/graphql-docs/layouts/includes/sidebar.html b/lib/graphql-docs/layouts/includes/sidebar.html
index 6f7c2be6..0f480f54 100644
--- a/lib/graphql-docs/layouts/includes/sidebar.html
+++ b/lib/graphql-docs/layouts/includes/sidebar.html
@@ -1,5 +1,7 @@