docit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/README.md +475 -21
- data/TROUBLESHOOTING.md +57 -0
- data/app/controllers/docit/ui_controller.rb +23 -3
- data/config/routes.rb +2 -0
- data/docs/images/scalar_image.png +0 -0
- data/docs/images/swagger_image.png +0 -0
- data/lib/docit/ai/autodoc_runner.rb +9 -4
- data/lib/docit/ai/doc_writer.rb +3 -3
- data/lib/docit/ai/groq_client.rb +4 -8
- data/lib/docit/ai/scaffold_generator.rb +3 -1
- data/lib/docit/ai/tag_injector.rb +1 -1
- data/lib/docit/builders/response_builder.rb +12 -2
- data/lib/docit/configuration.rb +5 -4
- data/lib/docit/schema_generator.rb +49 -31
- data/lib/docit/system_graph/edge.rb +29 -0
- data/lib/docit/system_graph/generator.rb +15 -0
- data/lib/docit/system_graph/graph.rb +55 -0
- data/lib/docit/system_graph/node.rb +32 -0
- data/lib/docit/system_graph/rails_analyzer.rb +278 -0
- data/lib/docit/system_graph/source_scanner.rb +76 -0
- data/lib/docit/system_graph.rb +8 -0
- data/lib/docit/ui/base_renderer.rb +49 -21
- data/lib/docit/ui/system_renderer.rb +113 -0
- data/lib/docit/ui/system_script.rb +1416 -0
- data/lib/docit/ui/system_styles.rb +566 -0
- data/lib/docit/version.rb +1 -1
- data/lib/docit.rb +4 -0
- data/lib/generators/docit/install/templates/initializer.rb +4 -0
- metadata +17 -4
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module SystemGraph
|
|
5
|
+
class RailsAnalyzer
|
|
6
|
+
VALID_METHODS = %w[get post put patch delete].freeze
|
|
7
|
+
SKIP_PREFIXES = %w[docit/ rails/ active_storage/ action_mailbox/].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(graph: Graph.new, scanner: SourceScanner.new(root: Rails.root))
|
|
10
|
+
@graph = graph
|
|
11
|
+
@scanner = scanner
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def analyze
|
|
15
|
+
add_routes_and_actions
|
|
16
|
+
add_schemas
|
|
17
|
+
add_models
|
|
18
|
+
add_source_nodes
|
|
19
|
+
# Runs last: Graph#add_edge drops edges to not-yet-created nodes, so
|
|
20
|
+
# schema-usage edges must be added after both doc and schema nodes exist.
|
|
21
|
+
add_schema_usage_edges
|
|
22
|
+
graph
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :graph, :scanner
|
|
28
|
+
|
|
29
|
+
def add_routes_and_actions
|
|
30
|
+
route_infos.each do |info|
|
|
31
|
+
controller_id = node_id("controller", info[:controller])
|
|
32
|
+
action_id = "#{controller_id}##{info[:action]}"
|
|
33
|
+
route_id = "route:#{info[:method]}:#{info[:path]}"
|
|
34
|
+
operation = Registry.find(controller: info[:controller], action: info[:action])
|
|
35
|
+
|
|
36
|
+
graph.add_node(Node.new(
|
|
37
|
+
id: controller_id,
|
|
38
|
+
type: "controller",
|
|
39
|
+
label: info[:controller],
|
|
40
|
+
file: controller_file(info[:controller]),
|
|
41
|
+
metadata: { controller_path: info[:controller_path] }
|
|
42
|
+
))
|
|
43
|
+
graph.add_node(Node.new(
|
|
44
|
+
id: action_id,
|
|
45
|
+
type: "action",
|
|
46
|
+
label: "#{info[:controller]}##{info[:action]}",
|
|
47
|
+
file: controller_file(info[:controller]),
|
|
48
|
+
status: operation ? "documented" : "undocumented",
|
|
49
|
+
metadata: { action: info[:action], http_method: info[:method], path: info[:path] }
|
|
50
|
+
))
|
|
51
|
+
graph.add_node(Node.new(
|
|
52
|
+
id: route_id,
|
|
53
|
+
type: "route",
|
|
54
|
+
label: "#{info[:method].upcase} #{info[:path]}",
|
|
55
|
+
metadata: { method: info[:method], path: info[:path] },
|
|
56
|
+
status: operation ? "documented" : "undocumented"
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
graph.add_edge(edge(route_id, action_id, "routes_to", "high", "Rails route table"))
|
|
60
|
+
graph.add_edge(edge(controller_id, action_id, "contains", "high", "Rails controller action"))
|
|
61
|
+
add_doc_node(info, operation, action_id) if operation
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add_doc_node(info, operation, action_id)
|
|
66
|
+
doc_id = "doc:#{info[:controller].underscore}:#{info[:action]}"
|
|
67
|
+
|
|
68
|
+
request_body_info = nil
|
|
69
|
+
if operation._request_body
|
|
70
|
+
request_body_info = {
|
|
71
|
+
required: operation._request_body.required,
|
|
72
|
+
content_type: operation._request_body.content_type,
|
|
73
|
+
schema_ref: operation._request_body.schema_ref,
|
|
74
|
+
properties: operation._request_body.properties
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
responses_info = operation._responses.map do |res|
|
|
79
|
+
{
|
|
80
|
+
status: res.status,
|
|
81
|
+
description: res.description,
|
|
82
|
+
schema_ref: res.schema_ref,
|
|
83
|
+
properties: res.properties,
|
|
84
|
+
examples: res.examples
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
parameters_info = operation._parameters.params.map do |param|
|
|
89
|
+
{
|
|
90
|
+
name: param[:name],
|
|
91
|
+
location: param[:in],
|
|
92
|
+
type: param[:schema][:type],
|
|
93
|
+
required: param[:required],
|
|
94
|
+
description: param[:description]
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
graph.add_node(Node.new(
|
|
99
|
+
id: doc_id,
|
|
100
|
+
type: "doc",
|
|
101
|
+
label: operation._summary || "#{info[:controller]}##{info[:action]}",
|
|
102
|
+
metadata: {
|
|
103
|
+
controller: info[:controller],
|
|
104
|
+
action: info[:action],
|
|
105
|
+
description: operation._description,
|
|
106
|
+
tags: operation._tags,
|
|
107
|
+
request_body: request_body_info,
|
|
108
|
+
responses: responses_info,
|
|
109
|
+
parameters: parameters_info
|
|
110
|
+
},
|
|
111
|
+
status: "documented"
|
|
112
|
+
))
|
|
113
|
+
graph.add_edge(edge(doc_id, action_id, "documents", "high", "Docit registry"))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Link each doc node to every shared schema it references via $ref (in its
|
|
117
|
+
# request body or any response). Runs as a final pass because add_edge only
|
|
118
|
+
# keeps edges whose endpoints already exist as nodes. Confidence is high —
|
|
119
|
+
# the references come straight from the Docit registry.
|
|
120
|
+
def add_schema_usage_edges
|
|
121
|
+
graph.nodes.values.select { |node| node.type == "doc" }.each do |doc|
|
|
122
|
+
operation = Registry.find(controller: doc.metadata[:controller], action: doc.metadata[:action])
|
|
123
|
+
next unless operation
|
|
124
|
+
|
|
125
|
+
schema_refs_for(operation).each do |ref|
|
|
126
|
+
graph.add_edge(edge(doc.id, node_id("schema", ref), "uses_schema", "high", "Docit registry $ref"))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def schema_refs_for(operation)
|
|
132
|
+
refs = [operation._request_body&.schema_ref]
|
|
133
|
+
operation._responses.each { |res| refs << res.schema_ref }
|
|
134
|
+
refs.compact.uniq
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def add_schemas
|
|
138
|
+
Docit.schemas.each_key do |name|
|
|
139
|
+
graph.add_node(Node.new(
|
|
140
|
+
id: node_id("schema", name),
|
|
141
|
+
type: "schema",
|
|
142
|
+
label: name.to_s,
|
|
143
|
+
metadata: { properties: Docit.schemas[name].properties.map { |prop| prop[:name].to_s } }
|
|
144
|
+
))
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def add_models
|
|
149
|
+
return unless defined?(ActiveRecord::Base)
|
|
150
|
+
|
|
151
|
+
ActiveRecord::Base.descendants.each do |model|
|
|
152
|
+
next if model.name.nil?
|
|
153
|
+
|
|
154
|
+
model_id = node_id("model", model.name)
|
|
155
|
+
graph.add_node(Node.new(
|
|
156
|
+
id: model_id,
|
|
157
|
+
type: "model",
|
|
158
|
+
label: model.name,
|
|
159
|
+
file: model_file(model.name),
|
|
160
|
+
metadata: { table_name: table_name(model) }
|
|
161
|
+
))
|
|
162
|
+
add_model_associations(model, model_id)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def add_model_associations(model, model_id)
|
|
167
|
+
return unless model.respond_to?(:reflect_on_all_associations)
|
|
168
|
+
|
|
169
|
+
model.reflect_on_all_associations.each do |association|
|
|
170
|
+
target = association.class_name
|
|
171
|
+
target_id = node_id("model", target)
|
|
172
|
+
next unless graph.nodes.key?(target_id)
|
|
173
|
+
|
|
174
|
+
graph.add_edge(edge(model_id, target_id, "association", "high",
|
|
175
|
+
"ActiveRecord reflection: #{association.name}"))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def add_source_nodes
|
|
180
|
+
source_nodes = scanner.source_nodes
|
|
181
|
+
source_nodes.each { |node| graph.add_node(node) }
|
|
182
|
+
|
|
183
|
+
labels = graph.nodes.values.select { |node| %w[model service job mailer].include?(node.type) }.map(&:label)
|
|
184
|
+
sources = graph.nodes.values.select { |node| %w[controller action service job mailer].include?(node.type) }
|
|
185
|
+
sources.each do |source|
|
|
186
|
+
scanner.references_for(source.file, labels).each do |label|
|
|
187
|
+
target = graph.nodes.values.find { |node| node.label == label }
|
|
188
|
+
next unless target
|
|
189
|
+
|
|
190
|
+
graph.add_edge(edge(source.id, target.id, edge_type_for(target), "medium",
|
|
191
|
+
"Constant reference in #{source.file}"))
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def route_infos
|
|
197
|
+
return [] if defined?(Rails).nil? || Rails.application.routes.nil?
|
|
198
|
+
|
|
199
|
+
Rails.application.routes.routes.filter_map do |route|
|
|
200
|
+
controller_path = route.defaults[:controller]
|
|
201
|
+
action = route.defaults[:action]
|
|
202
|
+
next if controller_path.nil? || action.nil?
|
|
203
|
+
next if skip_route?(controller_path)
|
|
204
|
+
|
|
205
|
+
method = extract_verb(route)
|
|
206
|
+
next if VALID_METHODS.exclude?(method)
|
|
207
|
+
|
|
208
|
+
controller = "#{controller_path}_controller".camelize
|
|
209
|
+
next if excluded_path?(controller_file(controller))
|
|
210
|
+
|
|
211
|
+
{
|
|
212
|
+
controller: controller,
|
|
213
|
+
controller_path: controller_path,
|
|
214
|
+
action: action.to_s,
|
|
215
|
+
method: method,
|
|
216
|
+
path: normalize_path(route.path.spec.to_s)
|
|
217
|
+
}
|
|
218
|
+
end.uniq
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def edge(source, target, type, confidence, evidence)
|
|
222
|
+
Edge.new(
|
|
223
|
+
id: "#{type}:#{source}->#{target}",
|
|
224
|
+
source: source,
|
|
225
|
+
target: target,
|
|
226
|
+
type: type,
|
|
227
|
+
confidence: confidence,
|
|
228
|
+
evidence: evidence
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def node_id(type, label)
|
|
233
|
+
"#{type}:#{label.to_s.underscore.tr("/", ":")}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def controller_file(controller)
|
|
237
|
+
"app/controllers/#{controller.underscore}.rb"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def model_file(model)
|
|
241
|
+
"app/models/#{model.underscore}.rb"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def edge_type_for(target)
|
|
245
|
+
target.type == "model" ? "uses_model" : "calls"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def table_name(model)
|
|
249
|
+
model.table_name if model.respond_to?(:table_name)
|
|
250
|
+
rescue ActiveRecord::StatementInvalid
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def skip_route?(controller_path)
|
|
255
|
+
SKIP_PREFIXES.any? { |prefix| controller_path.start_with?(prefix) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def excluded_path?(path)
|
|
259
|
+
Docit.configuration.system_graph_excluded_paths.any? do |excluded|
|
|
260
|
+
path.start_with?(excluded.to_s)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def extract_verb(route)
|
|
265
|
+
verb = route.verb
|
|
266
|
+
verb = verb.source if verb.is_a?(Regexp)
|
|
267
|
+
verb.to_s.downcase.gsub(/[^a-z]/, "")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def normalize_path(path)
|
|
271
|
+
path
|
|
272
|
+
.gsub("(.:format)", "")
|
|
273
|
+
.gsub(/\(\.?:(\w+)\)/, '{\1}')
|
|
274
|
+
.gsub(/:(\w+)/, '{\1}')
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module SystemGraph
|
|
5
|
+
class SourceScanner
|
|
6
|
+
SOURCE_TYPES = {
|
|
7
|
+
"app/services" => "service",
|
|
8
|
+
"app/jobs" => "job",
|
|
9
|
+
"app/mailers" => "mailer"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(root:, excluded_paths: Docit.configuration.system_graph_excluded_paths)
|
|
13
|
+
@root = root
|
|
14
|
+
@excluded_paths = excluded_paths.map(&:to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def source_nodes
|
|
18
|
+
SOURCE_TYPES.each_with_object([]) do |(dir, type), nodes|
|
|
19
|
+
scan_dir(dir).each do |path|
|
|
20
|
+
relative = relative_path(path)
|
|
21
|
+
label = constant_name(relative, dir)
|
|
22
|
+
nodes << Node.new(
|
|
23
|
+
id: node_id(type, label),
|
|
24
|
+
type: type,
|
|
25
|
+
label: label,
|
|
26
|
+
file: relative,
|
|
27
|
+
metadata: { path: relative }
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def references_for(path, candidates)
|
|
34
|
+
return [] unless path && File.exist?(full_path(path))
|
|
35
|
+
|
|
36
|
+
content = File.read(full_path(path))
|
|
37
|
+
candidates.select do |candidate|
|
|
38
|
+
content.match?(/\b#{Regexp.escape(candidate)}\b/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :root, :excluded_paths
|
|
45
|
+
|
|
46
|
+
def scan_dir(dir)
|
|
47
|
+
full_dir = root.join(dir)
|
|
48
|
+
return [] unless Dir.exist?(full_dir)
|
|
49
|
+
|
|
50
|
+
Dir.glob(full_dir.join("**", "*.rb")).sort.reject do |path|
|
|
51
|
+
excluded?(relative_path(path))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def relative_path(path)
|
|
56
|
+
path.to_s.sub("#{root}/", "")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def constant_name(relative, dir)
|
|
60
|
+
relative.delete_prefix("#{dir}/").delete_suffix(".rb").camelize
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def node_id(type, label)
|
|
64
|
+
"#{type}:#{label.underscore.tr("/", ":")}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def full_path(path)
|
|
68
|
+
root.join(path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def excluded?(path)
|
|
72
|
+
excluded_paths.any? { |excluded| path.start_with?(excluded) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "system_graph/node"
|
|
4
|
+
require_relative "system_graph/edge"
|
|
5
|
+
require_relative "system_graph/graph"
|
|
6
|
+
require_relative "system_graph/source_scanner"
|
|
7
|
+
require_relative "system_graph/rails_analyzer"
|
|
8
|
+
require_relative "system_graph/generator"
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
module Docit
|
|
4
4
|
module UI
|
|
5
5
|
class BaseRenderer
|
|
6
|
-
attr_reader :spec_url, :title, :nav_paths
|
|
6
|
+
attr_reader :spec_url, :system_url, :title, :nav_paths
|
|
7
7
|
|
|
8
|
-
def initialize(spec_url:, nav_paths: {})
|
|
8
|
+
def initialize(spec_url:, system_url: nil, nav_paths: {})
|
|
9
9
|
@spec_url = spec_url
|
|
10
|
+
@system_url = system_url
|
|
10
11
|
@nav_paths = nav_paths
|
|
11
12
|
@title = ERB::Util.html_escape(Docit.configuration.title)
|
|
12
13
|
end
|
|
@@ -20,35 +21,62 @@ module Docit
|
|
|
20
21
|
def nav_bar(active:)
|
|
21
22
|
swagger_active = active == :swagger
|
|
22
23
|
scalar_active = active == :scalar
|
|
24
|
+
system_active = active == :system
|
|
23
25
|
|
|
24
26
|
<<~HTML
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
<style>
|
|
28
|
+
/* Theme-aware nav. Falls back to the original dark values when the
|
|
29
|
+
page has no theme tokens (e.g. Swagger/Scalar renderers). */
|
|
30
|
+
.docit-nav {
|
|
31
|
+
display: flex; align-items: center; gap: 8px;
|
|
32
|
+
padding: 6px 16px;
|
|
33
|
+
background: var(--bg-glass, #111827);
|
|
34
|
+
color: var(--text, #ffffff);
|
|
35
|
+
border-bottom: 1px solid var(--border, transparent);
|
|
36
|
+
backdrop-filter: blur(12px) saturate(150%);
|
|
37
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
38
|
+
font-size: 13px; position: sticky; top: 0; z-index: 9999;
|
|
39
|
+
}
|
|
40
|
+
.docit-nav-link {
|
|
41
|
+
color: var(--haze, rgba(255,255,255,0.7)); text-decoration: none;
|
|
42
|
+
padding: 4px 12px; border-radius: 6px;
|
|
43
|
+
transition: all 150ms;
|
|
44
|
+
font-family: inherit;
|
|
45
|
+
font-size: inherit;
|
|
46
|
+
}
|
|
47
|
+
.docit-nav-link:hover {
|
|
48
|
+
color: var(--text, #ffffff); background: var(--bg-option-hover, rgba(255,255,255,0.15));
|
|
49
|
+
}
|
|
50
|
+
.docit-nav-link.active {
|
|
51
|
+
color: var(--ember, #ffffff); background: var(--bg-option-hover, rgba(255,255,255,0.15)); font-weight: 600;
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
54
|
+
<nav class="docit-nav">
|
|
32
55
|
<span style="font-weight: 600; margin-right: auto;">#{title}</span>
|
|
33
|
-
#{
|
|
34
|
-
#{
|
|
56
|
+
<a href="#{ERB::Util.html_escape(nav_paths[:swagger])}" class="docit-nav-link #{"active" if swagger_active}">Swagger</a>
|
|
57
|
+
<a href="#{ERB::Util.html_escape(nav_paths[:scalar])}" class="docit-nav-link #{"active" if scalar_active}">Scalar</a>
|
|
58
|
+
<a href="#{ERB::Util.html_escape(nav_paths[:system])}" class="docit-nav-link #{"active" if system_active}">System</a>
|
|
35
59
|
</nav>
|
|
36
60
|
HTML
|
|
37
61
|
end
|
|
38
62
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"color: #fff; text-decoration: none; padding: 4px 12px; border-radius: 4px; background: rgba(255,255,255,0.15); font-weight: 500;"
|
|
43
|
-
else
|
|
44
|
-
"color: rgba(255,255,255,0.7); text-decoration: none; padding: 4px 12px; border-radius: 4px;"
|
|
45
|
-
end
|
|
63
|
+
def spec_url_json
|
|
64
|
+
json_escape(JSON.generate(spec_url))
|
|
65
|
+
end
|
|
46
66
|
|
|
47
|
-
|
|
67
|
+
def system_url_json
|
|
68
|
+
json_escape(JSON.generate(system_url))
|
|
48
69
|
end
|
|
49
70
|
|
|
50
|
-
def
|
|
51
|
-
|
|
71
|
+
def json_escape(json_string)
|
|
72
|
+
json_string.to_s.gsub(/[&<>'\u2028\u2029]/, {
|
|
73
|
+
"&" => '\u0026',
|
|
74
|
+
"<" => '\u003c',
|
|
75
|
+
">" => '\u003e',
|
|
76
|
+
"'" => '\u0027',
|
|
77
|
+
"\u2028" => '\u2028',
|
|
78
|
+
"\u2029" => '\u2029'
|
|
79
|
+
})
|
|
52
80
|
end
|
|
53
81
|
end
|
|
54
82
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module UI
|
|
5
|
+
class SystemRenderer < BaseRenderer
|
|
6
|
+
def render
|
|
7
|
+
<<~HTML
|
|
8
|
+
<!DOCTYPE html>
|
|
9
|
+
<html lang="en">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="UTF-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
13
|
+
<title>#{title} – System Architecture</title>
|
|
14
|
+
<script>
|
|
15
|
+
/* Apply the saved theme before first paint to avoid a flash.
|
|
16
|
+
Default is light; only switch to dark when explicitly chosen. */
|
|
17
|
+
(function() {
|
|
18
|
+
try {
|
|
19
|
+
var saved = localStorage.getItem("docit-theme");
|
|
20
|
+
if (saved === "dark") document.documentElement.setAttribute("data-theme", "dark");
|
|
21
|
+
} catch (e) {}
|
|
22
|
+
})();
|
|
23
|
+
</script>
|
|
24
|
+
<style>#{SystemStyles.css}</style>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
#{nav_bar(active: :system)}
|
|
28
|
+
<div class="system-shell">
|
|
29
|
+
<header class="system-toolbar">
|
|
30
|
+
<div class="toolbar-group toolbar-brand">
|
|
31
|
+
<div class="brand-mark" aria-hidden="true">
|
|
32
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
|
33
|
+
<rect x="2" y="2" width="5" height="5" rx="1.2"/><rect x="9" y="2" width="5" height="5" rx="1.2"/>
|
|
34
|
+
<rect x="2" y="9" width="5" height="5" rx="1.2"/><rect x="9" y="9" width="5" height="5" rx="1.2"/>
|
|
35
|
+
<path d="M7 4.5h2M4.5 7v2M11.5 7v2"/>
|
|
36
|
+
</svg>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="toolbar-title">
|
|
39
|
+
<strong>System Architecture</strong>
|
|
40
|
+
<span>Interactive diagram · drag, zoom, explore</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="toolbar-group toolbar-filters" id="diagram-filters">
|
|
44
|
+
<input id="search" class="control" type="search" placeholder="Search nodes…" autocomplete="off">
|
|
45
|
+
<select id="diagram-section-filter" class="control"><option value="">All sections</option></select>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="toolbar-group toolbar-filters" id="docs-filters" style="display: none;">
|
|
48
|
+
<select id="section-filter" class="control"><option value="">All sections</option></select>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="toolbar-group toolbar-view-mode" style="margin-left: 6px; border-left: 1px solid var(--border); padding-left: 10px; gap: 4px;">
|
|
51
|
+
<button id="view-diagram" class="system-btn active" type="button">Diagram</button>
|
|
52
|
+
<button id="view-list" class="system-btn" type="button">Docs</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="toolbar-group toolbar-zoom">
|
|
55
|
+
<button id="zoom-out" class="system-btn icon-btn" type="button" title="Zoom out" aria-label="Zoom out">
|
|
56
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8h10"/></svg>
|
|
57
|
+
</button>
|
|
58
|
+
<span id="zoom-level" class="zoom-label">100%</span>
|
|
59
|
+
<button id="zoom-in" class="system-btn icon-btn" type="button" title="Zoom in" aria-label="Zoom in">
|
|
60
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
|
61
|
+
</button>
|
|
62
|
+
<button id="zoom-fit" class="system-btn" type="button">Fit</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="toolbar-group toolbar-actions">
|
|
65
|
+
<button id="theme-toggle" class="system-btn icon-btn" type="button" title="Toggle theme" aria-label="Toggle light or dark theme"></button>
|
|
66
|
+
<button id="export-png" class="system-btn" type="button">
|
|
67
|
+
<span class="btn-icon" aria-hidden="true">
|
|
68
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8M5 7.5l3 3 3-3M3 13h10"/></svg>
|
|
69
|
+
</span> Export PNG
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="toolbar-group toolbar-info">
|
|
73
|
+
<span id="stats" class="stat-pill"></span>
|
|
74
|
+
</div>
|
|
75
|
+
</header>
|
|
76
|
+
|
|
77
|
+
<div class="system-body">
|
|
78
|
+
<main class="canvas-wrap" id="canvas-wrap">
|
|
79
|
+
<div id="canvas" class="canvas"></div>
|
|
80
|
+
<div id="legend" class="legend collapsed">
|
|
81
|
+
<button id="legend-toggle" class="legend-toggle" type="button">Legend ▸</button>
|
|
82
|
+
<div id="legend-content" class="legend-content"></div>
|
|
83
|
+
</div>
|
|
84
|
+
</main>
|
|
85
|
+
<main class="stripe-docs-wrap" id="stripe-docs-wrap" style="display: none;">
|
|
86
|
+
<nav class="stripe-sidebar" id="stripe-sidebar"></nav>
|
|
87
|
+
<div class="stripe-content" id="stripe-content"></div>
|
|
88
|
+
<aside class="stripe-detail" id="stripe-detail">
|
|
89
|
+
<button class="stripe-detail-close" id="stripe-detail-close" type="button" aria-label="Close panel">
|
|
90
|
+
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
|
91
|
+
</button>
|
|
92
|
+
<div class="stripe-detail-body" id="stripe-detail-body">
|
|
93
|
+
<div class="stripe-detail-empty">
|
|
94
|
+
<div class="stripe-detail-empty-icon" aria-hidden="true">
|
|
95
|
+
<svg width="26" height="26" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 1.5h6L13 5v9.5H3z"/><path d="M9 1.5V5h4"/><path d="M5.5 8.5h5M5.5 11h3"/></svg>
|
|
96
|
+
</div>
|
|
97
|
+
<p>Select an endpoint to see how to call it.</p>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</aside>
|
|
101
|
+
</main>
|
|
102
|
+
<aside id="panel" class="panel"></aside>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div id="toast" class="toast" role="status"></div>
|
|
106
|
+
<script>#{SystemScript.javascript(graph_url: system_url)}</script>
|
|
107
|
+
</body>
|
|
108
|
+
</html>
|
|
109
|
+
HTML
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|