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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +145 -0
- data/elderdocs.yml.example +49 -0
- data/exe/elderdocs +7 -0
- data/lib/elder_docs/assets/ui_config.html +481 -0
- data/lib/elder_docs/assets/viewer/assets/index-161b367b.css +1 -0
- data/lib/elder_docs/assets/viewer/assets/index-886f4858.js +68 -0
- data/lib/elder_docs/assets/viewer/data.js +2 -0
- data/lib/elder_docs/assets/viewer/index.html +20 -0
- data/lib/elder_docs/cli.rb +69 -0
- data/lib/elder_docs/config.rb +55 -0
- data/lib/elder_docs/engine/ui_config_controller.rb +145 -0
- data/lib/elder_docs/engine.rb +96 -0
- data/lib/elder_docs/generator.rb +263 -0
- data/lib/elder_docs/version.rb +6 -0
- data/lib/elder_docs.rb +12 -0
- metadata +145 -0
|
@@ -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
|
+
|