elder_docs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ window.ElderDocsData = {"openapi":{"openapi":"3.0.0","info":{"title":"Test API - ElderDocs Demo","version":"1.0.0","description":"A comprehensive test API showcasing various endpoints, methods, and payloads for ElderDocs demonstration"},"servers":[{"url":"https://jsonplaceholder.typicode.com","description":"Test server (JSONPlaceholder)"},{"url":"https://api.example.com","description":"Production server"}],"paths":{"/posts":{"get":{"summary":"Get all posts","description":"Retrieve a list of all blog posts with optional filtering and pagination","parameters":[{"name":"userId","in":"query","description":"Filter posts by user ID","required":false,"schema":{"type":"integer"}},{"name":"_limit","in":"query","description":"Limit the number of results","required":false,"schema":{"type":"integer","default":10}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Post"}}}}}}},"post":{"summary":"Create a new post","description":"Create a new blog post with title, body, and user ID","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePostRequest"},"example":{"title":"My New Post","body":"This is the content of my new post","userId":1}}}},"responses":{"201":{"description":"Post created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Bad request - invalid input"}}}},"/posts/{id}":{"get":{"summary":"Get post by ID","description":"Retrieve a specific post by its unique identifier","parameters":[{"name":"id","in":"path","required":true,"description":"Post ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"404":{"description":"Post not found"}}},"put":{"summary":"Update entire post","description":"Replace all fields of an existing post","parameters":[{"name":"id","in":"path","required":true,"description":"Post ID","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePostRequest"},"example":{"id":1,"title":"Updated Post Title","body":"Updated post content","userId":1}}}},"responses":{"200":{"description":"Post updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}}}},"patch":{"summary":"Partially update post","description":"Update only specified fields of a post","parameters":[{"name":"id","in":"path","required":true,"description":"Post ID","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"body":{"type":"string"}}},"example":{"title":"Partially Updated Title"}}}},"responses":{"200":{"description":"Post partially updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}}}},"delete":{"summary":"Delete a post","description":"Permanently delete a post by ID","parameters":[{"name":"id","in":"path","required":true,"description":"Post ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"Post deleted successfully"},"404":{"description":"Post not found"}}}},"/users":{"get":{"summary":"List all users","description":"Get a list of all registered users","responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/User"}}}}}}}},"/users/{id}":{"get":{"summary":"Get user by ID","description":"Retrieve detailed information about a specific user","parameters":[{"name":"id","in":"path","required":true,"description":"User ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"User found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"404":{"description":"User not found"}}}},"/comments":{"get":{"summary":"Get all comments","description":"Retrieve comments with optional filtering","parameters":[{"name":"postId","in":"query","description":"Filter comments by post ID","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"description":"List of comments","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}}}}}}},"post":{"summary":"Create a comment","description":"Add a new comment to a post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCommentRequest"},"example":{"postId":1,"name":"John Doe","email":"john@example.com","body":"This is a great post!"}}}},"responses":{"201":{"description":"Comment created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Comment"}}}}}}}},"components":{"schemas":{"Post":{"type":"object","properties":{"id":{"type":"integer","description":"Unique post identifier"},"title":{"type":"string","description":"Post title"},"body":{"type":"string","description":"Post content"},"userId":{"type":"integer","description":"ID of the user who created the post"}}},"CreatePostRequest":{"type":"object","required":["title","body","userId"],"properties":{"title":{"type":"string","description":"Post title","minLength":1,"maxLength":200},"body":{"type":"string","description":"Post content"},"userId":{"type":"integer","description":"User ID"}}},"UpdatePostRequest":{"type":"object","required":["id","title","body","userId"],"properties":{"id":{"type":"integer"},"title":{"type":"string"},"body":{"type":"string"},"userId":{"type":"integer"}}},"User":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"username":{"type":"string"},"email":{"type":"string","format":"email"},"address":{"type":"object","properties":{"street":{"type":"string"},"city":{"type":"string"},"zipcode":{"type":"string"}}},"phone":{"type":"string"},"website":{"type":"string"}}},"Comment":{"type":"object","properties":{"id":{"type":"integer"},"postId":{"type":"integer"},"name":{"type":"string"},"email":{"type":"string","format":"email"},"body":{"type":"string"}}},"CreateCommentRequest":{"type":"object","required":["postId","name","email","body"],"properties":{"postId":{"type":"integer"},"name":{"type":"string"},"email":{"type":"string","format":"email"},"body":{"type":"string"}}}}}},"articles":[{"id":"getting_started","title":"🚀 Getting Started","markdown_content":"# Welcome to ElderDocs!\n\nThis is a **dead simple** API documentation platform.\n\n## Quick Start\n\n1. **Select an endpoint** from the sidebar\n2. **Fill in parameters** (if needed)\n3. **Click SEND REQUEST**\n4. **See the response** instantly\n\nThat's it! No complicated setup needed.\n\n## Features\n\n- ✅ **Interactive API Explorer** - Try endpoints right in your browser\n- ✅ **Multiple Auth Types** - Bearer tokens, API keys, Basic auth, OAuth2\n- ✅ **Beautiful Design** - Bright yellow and white with sharp corners\n- ✅ **Big Fonts** - Easy to read, easy to use\n- ✅ **Real-time Testing** - See responses immediately\n\n## Authentication\n\nChoose your authentication method from the dropdown:\n\n- **Bearer Token**: Standard OAuth2 bearer tokens\n- **API Key Header**: Custom API key headers\n- **Basic Auth**: Username:password format\n- **OAuth 2.0**: OAuth token support\n\nEnter your credentials once, and they'll be saved for all requests!"},{"id":"api_basics","title":"📖 API Basics","markdown_content":"## Understanding HTTP Methods\n\n### GET\n\nUse GET to **retrieve** data. It's safe and doesn't modify anything.\n\n```bash\nGET /posts\n```\n\n### POST\n\nUse POST to **create** new resources.\n\n```bash\nPOST /posts\nContent-Type: application/json\n\n{\n \"title\": \"My Post\",\n \"body\": \"Content here\",\n \"userId\": 1\n}\n```\n\n### PUT\n\nUse PUT to **replace** an entire resource.\n\n```bash\nPUT /posts/1\n```\n\n### PATCH\n\nUse PATCH to **partially update** a resource.\n\n```bash\nPATCH /posts/1\n```\n\n### DELETE\n\nUse DELETE to **remove** a resource.\n\n```bash\nDELETE /posts/1\n```\n\n## Response Codes\n\n- **200 OK**: Success!\n- **201 Created**: Resource created\n- **400 Bad Request**: Invalid input\n- **404 Not Found**: Resource doesn't exist\n- **500 Server Error**: Something went wrong"},{"id":"testing_tips","title":"💡 Testing Tips","markdown_content":"## Pro Tips for Testing\n\n### 1. Start Simple\n\nBegin with GET requests - they're the easiest!\n\n### 2. Check Required Fields\n\nRequired parameters are marked with a red asterisk (*).\n\n### 3. Use Examples\n\nMany endpoints have example payloads. Copy and modify them!\n\n### 4. Read the Response\n\nThe response shows:\n\n- **Status code** (200, 404, etc.)\n- **Response time** (how fast the API responded)\n- **Response body** (the actual data)\n\n### 5. Try Different Auth Methods\n\nSwitch between authentication types to see which works best for your API.\n\n### 6. Path Parameters\n\nFor endpoints like `/posts/{id}`, enter the ID in the Path Parameters section.\n\n### 7. Query Parameters\n\nAdd filters and pagination using Query Parameters.\n\n## Common Issues\n\n**CORS Error?**\n\nSome APIs block browser requests. Use a CORS proxy or test from your backend.\n\n**401 Unauthorized?**\n\nCheck your authentication credentials.\n\n**404 Not Found?**\n\nVerify the endpoint path and any path parameters."}],"api_server":"https://jsonplaceholder.typicode.com","api_servers":[],"auth_types":["bearer","api_key","basic","oauth2"],"ui_config":{},"generated_at":"2025-11-24T14:13:51+05:30"};
2
+ window.dispatchEvent(new Event('elderdocs:data_loaded'));
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
10
+ <title>ElderDocs - API Documentation</title>
11
+ <script type="module" crossorigin src="/docs/assets/index-886f4858.js"></script>
12
+ <link rel="stylesheet" href="/docs/assets/index-161b367b.css">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script src="/docs/data.js"></script>
17
+
18
+ </body>
19
+ </html>
20
+
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'elder_docs/generator'
5
+
6
+ module ElderDocs
7
+ class CLI < Thor
8
+ desc 'deploy', 'Generate and deploy API documentation'
9
+ method_option :definitions, type: :string, default: 'definitions.json', aliases: '-d'
10
+ method_option :articles, type: :string, default: 'articles.json', aliases: '-a'
11
+ method_option :output, type: :string, default: nil, aliases: '-o'
12
+ method_option :api_server, type: :string, default: nil, desc: 'Default API server URL'
13
+ method_option :skip_build, type: :boolean, default: false, desc: 'Skip frontend build if assets exist'
14
+ method_option :force_build, type: :boolean, default: false, desc: 'Force rebuilding frontend assets'
15
+
16
+ def deploy
17
+ say '🚀 Starting ElderDocs deployment...', :green
18
+
19
+ definitions_path = options[:definitions]
20
+ articles_path = options[:articles]
21
+
22
+ unless File.exist?(definitions_path)
23
+ say "❌ Error: #{definitions_path} not found in current directory", :red
24
+ exit 1
25
+ end
26
+
27
+ unless File.exist?(articles_path)
28
+ say "⚠️ Warning: #{articles_path} not found. Creating empty articles file...", :yellow
29
+ File.write(articles_path, [].to_json)
30
+ end
31
+
32
+ generator = Generator.new(
33
+ definitions_path: definitions_path,
34
+ articles_path: articles_path,
35
+ output_path: options[:output] || default_output_path,
36
+ api_server: options[:api_server],
37
+ skip_build: options[:skip_build],
38
+ force_build: options[:force_build]
39
+ )
40
+
41
+ begin
42
+ generator.generate!
43
+ say '✅ Documentation generated successfully!', :green
44
+ say "📦 Assets placed in: #{generator.output_path}", :cyan
45
+ rescue Generator::ValidationError => e
46
+ say "❌ Validation Error: #{e.message}", :red
47
+ exit 1
48
+ rescue StandardError => e
49
+ say "❌ Error: #{e.message}", :red
50
+ say e.backtrace.first(5).join("\n"), :yellow if options[:verbose]
51
+ exit 1
52
+ end
53
+ end
54
+
55
+ desc 'version', 'Show ElderDocs version'
56
+ def version
57
+ say "ElderDocs version #{ElderDocs::VERSION}", :green
58
+ end
59
+
60
+ default_task :deploy
61
+
62
+ private
63
+
64
+ def default_output_path
65
+ File.join(File.dirname(__FILE__), '..', '..', 'lib', 'elder_docs', 'assets', 'viewer')
66
+ end
67
+ end
68
+ end
69
+
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module ElderDocs
6
+ class Config
7
+ attr_accessor :mount_path, :api_server, :auth_types, :ui_config, :admin_password
8
+
9
+ def initialize
10
+ @mount_path = nil
11
+ @api_server = nil
12
+ @auth_types = ['bearer', 'api_key', 'basic', 'oauth2']
13
+ @ui_config = {}
14
+ @admin_password = nil
15
+ load_config_file
16
+ end
17
+
18
+ def load_config_file
19
+ # Try to find config file in Rails root or current directory
20
+ config_paths = []
21
+
22
+ if defined?(Rails) && Rails.root
23
+ config_paths << Rails.root.join('elderdocs.yml').to_s
24
+ end
25
+
26
+ config_paths << File.join(Dir.pwd, 'elderdocs.yml')
27
+
28
+ config_path = config_paths.find { |path| File.exist?(path) }
29
+ return unless config_path
30
+
31
+ begin
32
+ config = YAML.load_file(config_path)
33
+ @mount_path = config['mount_path'] if config['mount_path']
34
+ @api_server = config['api_server'] if config['api_server']
35
+ @api_servers = config['api_servers'] if config['api_servers']
36
+ @auth_types = config['auth_types'] if config['auth_types']
37
+ @ui_config = config['ui'] if config['ui'] # YAML uses 'ui' key, but we store as ui_config
38
+ @admin_password = config['admin_password'] if config['admin_password']
39
+ rescue => e
40
+ warn "Warning: Could not load elderdocs.yml: #{e.message}"
41
+ end
42
+ end
43
+
44
+ attr_reader :api_servers
45
+
46
+ def self.instance
47
+ @instance ||= new
48
+ end
49
+ end
50
+
51
+ def self.config
52
+ Config.instance
53
+ end
54
+ end
55
+
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module ElderDocs
6
+ class Engine
7
+ class UiConfigController < ActionController::Base
8
+ include ActionController::MimeResponds
9
+
10
+ # Enable sessions for authentication
11
+ protect_from_forgery with: :null_session
12
+
13
+ # Simple session-based authentication
14
+ before_action :authenticate_admin, only: [:update]
15
+ before_action :check_auth_for_api, only: [:show]
16
+
17
+ def show_login
18
+ render json: { requires_auth: true }
19
+ end
20
+
21
+ def login
22
+ admin_password = ElderDocs.config.admin_password || ENV['ELDERDOCS_ADMIN_PASSWORD'] || 'admin'
23
+
24
+ if params[:password] == admin_password
25
+ session[:elderdocs_admin] = true
26
+ render json: { success: true, message: 'Authentication successful' }
27
+ else
28
+ render json: { success: false, error: 'Invalid password' }, status: :unauthorized
29
+ end
30
+ end
31
+
32
+ def show
33
+ # If requesting HTML, serve the UI configurator page
34
+ if request.format.html? || request.headers['Accept']&.include?('text/html')
35
+ ui_config_path = Engine.root.join('lib', 'elder_docs', 'assets', 'ui_config.html')
36
+ if ui_config_path.exist?
37
+ send_file ui_config_path, disposition: 'inline', type: 'text/html'
38
+ else
39
+ render plain: 'UI Configurator not found', status: :not_found
40
+ end
41
+ else
42
+ # API endpoint - return JSON config (auth checked in check_auth_for_api)
43
+ config_path = find_config_file
44
+ current_config = load_config(config_path)
45
+
46
+ render json: {
47
+ ui_config: current_config['ui'] || {},
48
+ config_path: config_path
49
+ }
50
+ end
51
+ end
52
+
53
+ def update
54
+ config_path = find_config_file
55
+
56
+ # Load existing config
57
+ config = load_config(config_path) || {}
58
+
59
+ # Update UI config
60
+ config['ui'] = {
61
+ 'font_heading' => params[:font_heading],
62
+ 'font_body' => params[:font_body],
63
+ 'colors' => {
64
+ 'primary' => params[:color_primary],
65
+ 'secondary' => params[:color_secondary],
66
+ 'background' => params[:color_background],
67
+ 'surface' => params[:color_surface]
68
+ },
69
+ 'corner_radius' => params[:corner_radius]
70
+ }
71
+
72
+ # Save to file
73
+ save_config(config_path, config)
74
+
75
+ # Reload config in memory
76
+ ElderDocs.config.load_config_file
77
+
78
+ render json: {
79
+ success: true,
80
+ message: 'UI configuration saved successfully',
81
+ ui_config: config['ui']
82
+ }
83
+ end
84
+
85
+ def logout
86
+ session[:elderdocs_admin] = nil
87
+ render json: { success: true, message: 'Logged out successfully' }
88
+ end
89
+
90
+ private
91
+
92
+ def authenticate_admin
93
+ unless session[:elderdocs_admin]
94
+ render json: { requires_auth: true, error: 'Authentication required' }, status: :unauthorized
95
+ end
96
+ end
97
+
98
+ def check_auth_for_api
99
+ # Only require auth for JSON API requests, not HTML
100
+ return if request.format.html? || request.headers['Accept']&.include?('text/html')
101
+
102
+ authenticate_admin
103
+ end
104
+
105
+ def find_config_file
106
+ config_paths = []
107
+
108
+ if defined?(Rails) && Rails.root
109
+ config_paths << Rails.root.join('elderdocs.yml').to_s
110
+ end
111
+
112
+ config_paths << File.join(Dir.pwd, 'elderdocs.yml')
113
+
114
+ config_path = config_paths.find { |path| File.exist?(path) }
115
+
116
+ # Create default config file if it doesn't exist
117
+ unless config_path
118
+ config_path = config_paths.first || File.join(Dir.pwd, 'elderdocs.yml')
119
+ File.write(config_path, {}.to_yaml)
120
+ end
121
+
122
+ config_path
123
+ end
124
+
125
+ def load_config(config_path)
126
+ return {} unless File.exist?(config_path)
127
+
128
+ begin
129
+ YAML.load_file(config_path) || {}
130
+ rescue => e
131
+ Rails.logger.error "Error loading config: #{e.message}"
132
+ {}
133
+ end
134
+ end
135
+
136
+ def save_config(config_path, config)
137
+ File.write(config_path, config.to_yaml)
138
+ rescue => e
139
+ Rails.logger.error "Error saving config: #{e.message}"
140
+ raise
141
+ end
142
+ end
143
+ end
144
+ end
145
+
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElderDocs
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ElderDocs
6
+
7
+ # Define routes inline
8
+ routes do
9
+ # UI Configuration endpoints (before catch-all)
10
+ get '/ui', to: 'engine/ui_config#show'
11
+ get '/ui/login', to: 'engine/ui_config#show_login'
12
+ post '/ui/login', to: 'engine/ui_config#login'
13
+ post '/ui/logout', to: 'engine/ui_config#logout'
14
+ post '/ui/config', to: 'engine/ui_config#update'
15
+
16
+ # Serve data.js explicitly before catch-all
17
+ get '/data.js', to: 'engine/docs#show', defaults: { path: 'data.js' }
18
+ root 'engine/docs#show', defaults: { path: 'index.html' }
19
+ get '/*path', to: 'engine/docs#show', defaults: { path: 'index.html' }
20
+ end
21
+
22
+ # Note: Engine must be manually mounted in routes.rb
23
+ # We provide a helper to check for conflicts
24
+ initializer 'elder_docs.check_routes' do |app|
25
+ mount_path = ElderDocs.config.mount_path || '/docs'
26
+
27
+ # Check if route already exists when routes are loaded
28
+ app.config.after_initialize do
29
+ begin
30
+ # Try to generate URL helper for the mount path
31
+ route_name = mount_path.gsub('/', '_').gsub('-', '_').sub(/^_/, '')
32
+ if app.routes.url_helpers.respond_to?("#{route_name}_path")
33
+ Rails.logger.warn "ElderDocs: Route #{mount_path} already exists. Please mount manually in config/routes.rb with a different path."
34
+ end
35
+ rescue => e
36
+ # Route doesn't exist, which is fine
37
+ end
38
+ end
39
+ end
40
+
41
+ # Load UI config controller
42
+ require_relative 'engine/ui_config_controller'
43
+
44
+ # Create a simple controller to serve the static files
45
+ # Use API base to avoid CSRF protection
46
+ class DocsController < ActionController::API
47
+ include ActionController::MimeResponds
48
+
49
+ def show
50
+ viewer_path = Engine.root.join('lib', 'elder_docs', 'assets', 'viewer')
51
+ requested_path = params[:path]
52
+ requested_path = requested_path.present? ? requested_path : 'index'
53
+ requested_path = [requested_path, params[:format]].compact.join('.')
54
+ requested_path = 'index.html' if requested_path == 'index'
55
+ file_path = viewer_path.join(requested_path)
56
+
57
+ # Security check: ensure file is within viewer directory
58
+ if file_path.exist? && file_path.to_s.start_with?(viewer_path.to_s)
59
+ send_file file_path, disposition: 'inline', type: mime_type_for(file_path)
60
+ else
61
+ # Fallback to index.html for SPA routing (but not for data.js or assets)
62
+ if requested_path.end_with?('.js') || requested_path.end_with?('.css') || requested_path.end_with?('.json')
63
+ head :not_found
64
+ else
65
+ send_file viewer_path.join('index.html'), disposition: 'inline', type: 'text/html'
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def mime_type_for(file_path)
73
+ ext = File.extname(file_path.to_s)
74
+ case ext
75
+ when '.js'
76
+ 'application/javascript'
77
+ when '.css'
78
+ 'text/css'
79
+ when '.json'
80
+ 'application/json'
81
+ when '.html'
82
+ 'text/html'
83
+ when '.png'
84
+ 'image/png'
85
+ when '.jpg', '.jpeg'
86
+ 'image/jpeg'
87
+ when '.svg'
88
+ 'image/svg+xml'
89
+ else
90
+ 'application/octet-stream'
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openapi_parser'
5
+ require 'fileutils'
6
+
7
+ module ElderDocs
8
+ class Generator
9
+ class ValidationError < StandardError; end
10
+
11
+ attr_reader :definitions_path, :articles_path, :output_path, :api_server, :skip_build, :force_build
12
+
13
+ def initialize(definitions_path:, articles_path:, output_path:, api_server: nil, skip_build: false, force_build: false)
14
+ @definitions_path = definitions_path
15
+ @articles_path = articles_path
16
+ @output_path = output_path
17
+ @api_server = api_server
18
+ @skip_build = skip_build
19
+ @force_build = force_build
20
+ end
21
+
22
+ def generate!
23
+ validate_definitions!
24
+ validate_articles!
25
+ compile_data!
26
+
27
+ # Check if assets already exist
28
+ assets_exist = File.exist?(File.join(output_path, 'index.html'))
29
+
30
+ if skip_build || (assets_exist && !force_build)
31
+ say '⏭️ Skipping frontend build (assets already exist)', :yellow
32
+ say '💾 Updating data.js only...', :cyan
33
+ # Still update data.js with latest compiled data
34
+ FileUtils.mkdir_p(output_path)
35
+ File.write(File.join(output_path, 'data.js'), @compiled_data_js)
36
+ say '✅ Data updated', :green
37
+ else
38
+ build_frontend!
39
+ copy_assets!
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def validate_definitions!
46
+ say '📋 Validating OpenAPI definitions...', :cyan
47
+
48
+ begin
49
+ # Parse JSON first to ensure it's valid JSON (read as UTF-8)
50
+ json_content = File.read(definitions_path, encoding: 'UTF-8')
51
+ parsed_json = JSON.parse(json_content)
52
+
53
+ # Basic OpenAPI structure validation
54
+ unless parsed_json.is_a?(Hash)
55
+ raise ValidationError, 'OpenAPI definition must be a JSON object'
56
+ end
57
+
58
+ unless parsed_json['openapi'] || parsed_json['swagger']
59
+ raise ValidationError, 'OpenAPI definition must include "openapi" or "swagger" field'
60
+ end
61
+
62
+ unless parsed_json['info']
63
+ raise ValidationError, 'OpenAPI definition must include "info" field'
64
+ end
65
+
66
+ unless parsed_json['paths']
67
+ raise ValidationError, 'OpenAPI definition must include "paths" field'
68
+ end
69
+
70
+ # Try to validate with OpenAPIParser, but don't fail if it has issues
71
+ begin
72
+ parser = OpenAPIParser.parse(
73
+ json_content,
74
+ {
75
+ strict_reference_validation: false,
76
+ coerce_value: false
77
+ }
78
+ )
79
+
80
+ unless parser.valid?
81
+ say '⚠️ Warning: OpenAPI parser validation failed, but continuing anyway', :yellow
82
+ end
83
+ rescue => parser_error
84
+ say "⚠️ Warning: OpenAPI parser error (#{parser_error.message}), but continuing with basic validation", :yellow
85
+ end
86
+
87
+ # Use the parsed JSON directly, convert symbol keys to string keys for consistency
88
+ @openapi_data = deep_stringify_keys(parsed_json)
89
+ say '✅ OpenAPI definitions validated', :green
90
+ rescue JSON::ParserError => e
91
+ raise ValidationError, "Invalid JSON in definitions file: #{e.message}"
92
+ rescue ValidationError
93
+ raise
94
+ rescue => e
95
+ raise ValidationError, "Error processing OpenAPI definition: #{e.message}"
96
+ end
97
+ end
98
+
99
+ def deep_stringify_keys(obj)
100
+ case obj
101
+ when Hash
102
+ obj.each_with_object({}) do |(key, value), result|
103
+ result[key.to_s] = deep_stringify_keys(value)
104
+ end
105
+ when Array
106
+ obj.map { |item| deep_stringify_keys(item) }
107
+ else
108
+ obj
109
+ end
110
+ end
111
+
112
+ def validate_articles!
113
+ say '📚 Validating articles...', :cyan
114
+
115
+ begin
116
+ # Read as UTF-8 to handle emojis and special characters
117
+ articles_content = File.read(articles_path, encoding: 'UTF-8')
118
+ @articles_data = JSON.parse(articles_content)
119
+
120
+ unless @articles_data.is_a?(Array)
121
+ raise ValidationError, 'articles.json must be an array'
122
+ end
123
+
124
+ @articles_data.each_with_index do |article, index|
125
+ validate_article!(article, index)
126
+ end
127
+
128
+ say '✅ Articles validated', :green
129
+ rescue JSON::ParserError => e
130
+ raise ValidationError, "Invalid JSON in articles file: #{e.message}"
131
+ end
132
+ end
133
+
134
+ def validate_article!(article, index)
135
+ required_fields = %w[id title markdown_content]
136
+ missing_fields = required_fields - article.keys
137
+
138
+ if missing_fields.any?
139
+ raise ValidationError, "Article at index #{index} missing required fields: #{missing_fields.join(', ')}"
140
+ end
141
+
142
+ unless article['id'].is_a?(String) && !article['id'].empty?
143
+ raise ValidationError, "Article at index #{index} must have a non-empty string 'id'"
144
+ end
145
+
146
+ unless article['title'].is_a?(String) && !article['title'].empty?
147
+ raise ValidationError, "Article at index #{index} must have a non-empty string 'title'"
148
+ end
149
+
150
+ unless article['markdown_content'].is_a?(String)
151
+ raise ValidationError, "Article at index #{index} must have a string 'markdown_content'"
152
+ end
153
+ end
154
+
155
+ def compile_data!
156
+ say '📦 Compiling data...', :cyan
157
+
158
+ # Get API server from: CLI option > config file > OpenAPI servers
159
+ final_api_server = api_server || ElderDocs.config.api_server || extract_api_server_from_openapi
160
+ final_api_servers = ElderDocs.config.api_servers || []
161
+
162
+ compiled_data = {
163
+ openapi: @openapi_data,
164
+ articles: @articles_data,
165
+ api_server: final_api_server,
166
+ api_servers: final_api_servers,
167
+ auth_types: ElderDocs.config.auth_types || ['bearer', 'api_key', 'basic', 'oauth2'],
168
+ ui_config: ElderDocs.config.ui_config || {},
169
+ generated_at: Time.now.iso8601
170
+ }
171
+
172
+ @compiled_data_js = <<~JS
173
+ window.ElderDocsData = #{compiled_data.to_json};
174
+ window.dispatchEvent(new Event('elderdocs:data_loaded'));
175
+ JS
176
+
177
+ say '✅ Data compiled', :green
178
+ end
179
+
180
+ def extract_api_server_from_openapi
181
+ servers = @openapi_data.dig('servers') || []
182
+ servers.first&.dig('url') || ''
183
+ end
184
+
185
+ def build_frontend!
186
+ say '🔨 Building frontend...', :cyan
187
+
188
+ frontend_dir = File.join(File.dirname(__FILE__), '..', '..', 'frontend')
189
+
190
+ unless Dir.exist?(frontend_dir)
191
+ raise ValidationError, "Frontend directory not found at #{frontend_dir}"
192
+ end
193
+
194
+ # Write compiled data to frontend public directory
195
+ public_dir = File.join(frontend_dir, 'public')
196
+ FileUtils.mkdir_p(public_dir)
197
+ File.write(File.join(public_dir, 'data.js'), @compiled_data_js)
198
+
199
+ # Check if node_modules exists, if not, run npm install
200
+ node_modules = File.join(frontend_dir, 'node_modules')
201
+ unless Dir.exist?(node_modules)
202
+ say '📦 Installing npm dependencies...', :cyan
203
+ system("cd #{frontend_dir} && npm install") || raise('Failed to install npm dependencies')
204
+ end
205
+
206
+ # Run Vite build
207
+ say '⚡ Running Vite build...', :cyan
208
+ build_success = system("cd #{frontend_dir} && npm run build")
209
+
210
+ unless build_success
211
+ raise ValidationError, 'Frontend build failed'
212
+ end
213
+
214
+ @build_output_dir = File.join(frontend_dir, 'dist')
215
+
216
+ unless Dir.exist?(@build_output_dir)
217
+ raise ValidationError, 'Build output directory not found'
218
+ end
219
+
220
+ say '✅ Frontend built successfully', :green
221
+ end
222
+
223
+ def copy_assets!
224
+ say '📁 Copying assets...', :cyan
225
+
226
+ FileUtils.mkdir_p(output_path)
227
+ FileUtils.rm_rf(Dir.glob(File.join(output_path, '*')))
228
+
229
+ # Copy all files from dist to output_path
230
+ Dir.glob(File.join(@build_output_dir, '**', '*')).each do |source|
231
+ next if File.directory?(source)
232
+
233
+ relative_path = source.sub(@build_output_dir + '/', '')
234
+ dest_path = File.join(output_path, relative_path)
235
+ FileUtils.mkdir_p(File.dirname(dest_path))
236
+ FileUtils.cp(source, dest_path)
237
+ end
238
+
239
+ # Fix asset paths in index.html to work with engine mount point
240
+ index_html_path = File.join(output_path, 'index.html')
241
+ if File.exist?(index_html_path)
242
+ html_content = File.read(index_html_path, encoding: 'UTF-8')
243
+ # Replace relative paths with paths relative to mount point
244
+ html_content.gsub!(/src="\.\//, 'src="/docs/')
245
+ html_content.gsub!(/href="\.\//, 'href="/docs/')
246
+ html_content.gsub!(/src="\/assets\//, 'src="/docs/assets/')
247
+ html_content.gsub!(/href="\/assets\//, 'href="/docs/assets/')
248
+ # Fix data.js path
249
+ html_content.gsub!(/src="\/data\.js/, 'src="/docs/data.js')
250
+ File.write(index_html_path, html_content)
251
+ end
252
+
253
+ say '✅ Assets copied', :green
254
+ end
255
+
256
+ def say(message, color = nil)
257
+ return unless defined?(Thor)
258
+
259
+ Thor::Base.shell.new.say(message, color)
260
+ end
261
+ end
262
+ end
263
+