archsight 0.4.2 → 0.5.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/Dockerfile +4 -0
- data/docs/configuration.md +5 -0
- data/docs/diagram.md +69 -0
- data/docs/index.md.erb +1 -0
- data/docs/modeling.md +3 -1
- data/docs/pdf-export.md +118 -0
- data/lib/archsight/analysis/typst_renderer.rb +25 -78
- data/lib/archsight/cli.rb +39 -1
- data/lib/archsight/database.rb +4 -3
- data/lib/archsight/diagram/cli.rb +11 -1
- data/lib/archsight/diagram/errors.rb +1 -0
- data/lib/archsight/diagram/graph/attributes.rb +3 -1
- data/lib/archsight/diagram/graph/node.rb +18 -1
- data/lib/archsight/diagram/includes.rb +168 -0
- data/lib/archsight/diagram/layout/expander.rb +75 -3
- data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
- data/lib/archsight/diagram/parser/ast.rb +5 -0
- data/lib/archsight/diagram/parser/table_layout.rb +2 -0
- data/lib/archsight/diagram/parser.rb +58 -16
- data/lib/archsight/diagram.rb +20 -4
- data/lib/archsight/export/confluence/storage.rb +6 -1
- data/lib/archsight/helpers/diagram_blocks.rb +34 -19
- data/lib/archsight/helpers/diagram_includes.rb +67 -0
- data/lib/archsight/helpers.rb +1 -0
- data/lib/archsight/linter.rb +10 -2
- data/lib/archsight/markdown.rb +17 -1
- data/lib/archsight/pdf/cache.rb +41 -0
- data/lib/archsight/pdf/compiler.rb +69 -0
- data/lib/archsight/pdf/dependency_graph.rb +127 -0
- data/lib/archsight/pdf/document.rb +227 -0
- data/lib/archsight/pdf/embeds.rb +134 -0
- data/lib/archsight/pdf/images.rb +94 -0
- data/lib/archsight/pdf/markdown.rb +217 -0
- data/lib/archsight/pdf/remote_image.rb +122 -0
- data/lib/archsight/pdf/resource_sheet.rb +129 -0
- data/lib/archsight/pdf/sample_document.rb +67 -0
- data/lib/archsight/pdf/tables.rb +72 -0
- data/lib/archsight/pdf/theme.rb +125 -0
- data/lib/archsight/pdf/typst/archsight.typ +152 -0
- data/lib/archsight/pdf/typst.rb +45 -0
- data/lib/archsight/pdf.rb +30 -0
- data/lib/archsight/references.rb +4 -3
- data/lib/archsight/resources/application_component.rb +10 -0
- data/lib/archsight/resources/application_service.rb +9 -0
- data/lib/archsight/resources/config.rb +147 -0
- data/lib/archsight/resources/technology_service.rb +9 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/json_helpers.rb +3 -2
- data/lib/archsight/web/api/openapi/spec.yaml +239 -2
- data/lib/archsight/web/api/page_helpers.rb +1 -1
- data/lib/archsight/web/api/pdf_helpers.rb +46 -0
- data/lib/archsight/web/api/routes.rb +34 -3
- data/lib/archsight/web/application.rb +2 -1
- data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
- data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
- data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
- data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
- data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
- data/lib/archsight/web/public/vue/{InstanceRouter-Ca_C8izS.css → InstanceRouter-BCKbW-Wp.css} +1 -1
- data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
- data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
- data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
- data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
- data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
- data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
- data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
- data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
- data/lib/archsight/web/public/vue/{index-yiZbbvAs.js → index-BF4zUlWZ.js} +2 -2
- data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
- data/lib/archsight/web/public/vue.html +2 -2
- metadata +37 -17
- data/lib/archsight/web/public/vue/DocPage-C8Ysway-.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-pr2MR6_b.js +0 -35
- data/lib/archsight/web/public/vue/ErrorPage-FAkpW-hn.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-liQJzFyo.js +0 -1
- data/lib/archsight/web/public/vue/HomePage-DYpzSXG8.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
- data/lib/archsight/web/public/vue/KindList-CrgI4qDb.js +0 -1
- data/lib/archsight/web/public/vue/PageView-BIxT_t49.js +0 -1
- data/lib/archsight/web/public/vue/QueryError-ChdyV8B1.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DdtaHY4k.js +0 -2
- data/lib/archsight/web/public/vue/SearchResults-BYRAcK_o.js +0 -1
- data/lib/archsight/web/public/vue/WikiPage-CgvRN6R2.js +0 -3
- data/lib/archsight/web/public/vue/index-BLSE5DSv.css +0 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archsight
|
|
4
|
+
module Diagram
|
|
5
|
+
# Expands `include "<path>"` statements: each one is replaced, at its position, by the statements of the
|
|
6
|
+
# diagram it names. The renderer knows nothing about where diagrams live; the caller supplies a loader
|
|
7
|
+
# (`archsight://` and `file://` paths, see `Helpers::DiagramIncludes`).
|
|
8
|
+
#
|
|
9
|
+
# `loader.call(path)` returns the source of the included diagram, or `:missing` / `:forbidden` / `:invalid`.
|
|
10
|
+
# Included sources may include others. An include inside a container takes blocks and edges only: a
|
|
11
|
+
# `dataflow` or a `theme`/`legend`/`ranks` setting belongs to the whole diagram, so it is left out there
|
|
12
|
+
# and reported in `Result#warnings`.
|
|
13
|
+
# `include "x" { namespace "cs" }` puts `cs.` in front of every id the included diagram defines (nodes and
|
|
14
|
+
# dataflows, and the references to them inside it), so the same diagram can be included more than once;
|
|
15
|
+
# a node without a `label` keeps showing its unprefixed id. References to ids the diagram does not define
|
|
16
|
+
# are left alone: they name nodes of the including diagram.
|
|
17
|
+
# Edges need no such care, they are resolved by id once the whole graph is built, so a fragment may link to
|
|
18
|
+
# nodes of the diagram that includes it and the other way round.
|
|
19
|
+
class Includes
|
|
20
|
+
MAX_DEPTH = 8
|
|
21
|
+
MAX_INCLUDES = 100
|
|
22
|
+
REASONS = { missing: "not found", forbidden: "is not allowed", invalid: "is not a valid include path" }.freeze
|
|
23
|
+
|
|
24
|
+
# The expanded statements, the sources that were loaded (`{ path => source }`, in order) and what was
|
|
25
|
+
# left out of them (warning messages).
|
|
26
|
+
Result = Struct.new(:statements, :sources, :warnings, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# @param statements [Array] parsed statements of the main source
|
|
29
|
+
# @param loader [#call, nil]
|
|
30
|
+
# @raise [IncludeError]
|
|
31
|
+
def self.expand(statements, loader)
|
|
32
|
+
new(loader).expand(statements)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.includes?(statements)
|
|
36
|
+
statements.any? { |s| s.is_a?(AST::Include) || (s.is_a?(AST::Block) && includes?(s.children)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(loader)
|
|
40
|
+
@loader = loader
|
|
41
|
+
@sources = {}
|
|
42
|
+
@warnings = []
|
|
43
|
+
@count = 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expand(statements)
|
|
47
|
+
Result.new(statements: splice(statements, [], top: true), sources: @sources, warnings: @warnings)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def splice(statements, stack, top:)
|
|
53
|
+
statements.flat_map do |stmt|
|
|
54
|
+
case stmt
|
|
55
|
+
when AST::Include then included(stmt, stack, top: top)
|
|
56
|
+
when AST::Block
|
|
57
|
+
stmt.children = splice(stmt.children, stack, top: false)
|
|
58
|
+
[stmt]
|
|
59
|
+
else [stmt]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def included(stmt, stack, top:)
|
|
65
|
+
path = stmt.path
|
|
66
|
+
raise IncludeError, "include cycle: #{(stack + [path]).join(" -> ")} (line #{stmt.line})" if stack.include?(path)
|
|
67
|
+
raise IncludeError, "include #{path.inspect} (line #{stmt.line}) is nested deeper than #{MAX_DEPTH} levels" if stack.length >= MAX_DEPTH
|
|
68
|
+
|
|
69
|
+
@count += 1
|
|
70
|
+
raise IncludeError, "too many includes (more than #{MAX_INCLUDES})" if @count > MAX_INCLUDES
|
|
71
|
+
|
|
72
|
+
source = load(path, stmt.line)
|
|
73
|
+
@sources[path] = source
|
|
74
|
+
parsed = parse(path, source)
|
|
75
|
+
parsed = drop_whole_diagram_statements(path, parsed, stmt.line) unless top
|
|
76
|
+
statements = splice(parsed, stack + [path], top: top)
|
|
77
|
+
stmt.namespace ? Namespacer.new(stmt.namespace).apply(statements) : statements
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load(path, line)
|
|
81
|
+
raise IncludeError, "include #{path.inspect} (line #{line}) is not available here" unless @loader
|
|
82
|
+
|
|
83
|
+
result = @loader.call(path)
|
|
84
|
+
return result if result.is_a?(String)
|
|
85
|
+
|
|
86
|
+
reason = REASONS.fetch(result) { raise ArgumentError, "include loader returned #{result.inspect} for #{path.inspect}" }
|
|
87
|
+
raise IncludeError, "include #{path.inspect} #{reason} (line #{line})"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse(path, source)
|
|
91
|
+
Parser.parse(source, anon_scope: "i#{@count}_")
|
|
92
|
+
rescue ParseError, GraphError => e
|
|
93
|
+
raise IncludeError, "in include #{path.inspect}: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Dataflows and settings apply to the whole diagram, so inside a container they are ignored (with a warning)
|
|
97
|
+
def drop_whole_diagram_statements(path, parsed, line)
|
|
98
|
+
dropped, kept = parsed.partition { |s| s.is_a?(AST::DataFlow) || s.is_a?(AST::Setting) }
|
|
99
|
+
dropped.each do |s|
|
|
100
|
+
what = s.is_a?(AST::DataFlow) ? "dataflow #{s.id.inspect}" : "#{s.key} setting"
|
|
101
|
+
@warnings << "include #{path.inspect} (line #{line}): the #{what} (line #{s.line}) is ignored, it only applies when included at the top level"
|
|
102
|
+
end
|
|
103
|
+
kept
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Puts `<namespace>.` in front of the ids a list of statements defines, and of the references to them
|
|
107
|
+
class Namespacer
|
|
108
|
+
def initialize(namespace)
|
|
109
|
+
@prefix = "#{namespace}."
|
|
110
|
+
@defined = {}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def apply(statements)
|
|
114
|
+
collect(statements)
|
|
115
|
+
statements.each { |stmt| rename(stmt) }
|
|
116
|
+
statements
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Anonymous layers/stacks have ids no one can reference and that are unique already
|
|
122
|
+
def collect(statements)
|
|
123
|
+
statements.each do |stmt|
|
|
124
|
+
case stmt
|
|
125
|
+
when AST::Block
|
|
126
|
+
@defined[stmt.id] = true unless stmt.anonymous?
|
|
127
|
+
collect(stmt.children)
|
|
128
|
+
when AST::DataFlow then @defined[stmt.id] = true
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def rename(stmt)
|
|
134
|
+
case stmt
|
|
135
|
+
when AST::Block then rename_block(stmt)
|
|
136
|
+
when AST::Edge
|
|
137
|
+
stmt.from = ref(stmt.from)
|
|
138
|
+
stmt.to = ref(stmt.to)
|
|
139
|
+
when AST::DataFlow then rename_dataflow(stmt)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def rename_block(block)
|
|
144
|
+
unless block.anonymous?
|
|
145
|
+
block.attrs["label"] ||= block.id # the label defaults to the id
|
|
146
|
+
block.id = ref(block.id)
|
|
147
|
+
end
|
|
148
|
+
block.children.each { |child| rename(child) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def rename_dataflow(flow)
|
|
152
|
+
flow.attrs["label"] ||= flow.id
|
|
153
|
+
flow.id = ref(flow.id)
|
|
154
|
+
flow.hops.map! { |hop| hop.is_a?(AST::DataFlowGroup) ? rename_group(hop) : ref(hop) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def rename_group(group)
|
|
158
|
+
group.branches.each { |branch| branch.hops.map! { |hop| ref(hop) } }
|
|
159
|
+
group
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def ref(id)
|
|
163
|
+
@defined[id] ? "#{@prefix}#{id}" : id
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -97,29 +97,101 @@ module Archsight
|
|
|
97
97
|
# their own (now wider) box -- explaining a rank that visibly
|
|
98
98
|
# spills outside its container despite matching its siblings'
|
|
99
99
|
# width exactly.
|
|
100
|
-
|
|
100
|
+
#
|
|
101
|
+
# The room they get is the *inner* one -- the target minus the
|
|
102
|
+
# node's own padding (and title bar), as in the single-child
|
|
103
|
+
# case below -- or they would sit flush on its border; the
|
|
104
|
+
# centerline is the middle of that inner area.
|
|
105
|
+
inner = own_target - inset(node, axis)
|
|
106
|
+
center = leading_inset(node, axis) + (inner / 2.0)
|
|
101
107
|
node.children.each do |c|
|
|
102
|
-
expand(c, axis,
|
|
108
|
+
expand(c, axis, inner, inherited: extend)
|
|
103
109
|
axis.set_position(@boxes[c.id], center)
|
|
104
110
|
end
|
|
105
111
|
end
|
|
106
112
|
return
|
|
107
113
|
end
|
|
108
114
|
|
|
109
|
-
recenter_children(node, axis, extra
|
|
115
|
+
recenter_children(node, axis, node.anchor_shift(axis, extra, default: :center)) if extra.positive?
|
|
110
116
|
|
|
111
117
|
return unless node.children.length == 1
|
|
112
118
|
|
|
113
119
|
expand(node.children.first, axis, own_target - inset(node, axis), inherited: extend)
|
|
114
120
|
end
|
|
115
121
|
|
|
122
|
+
# `extend "height"`: grows `node` to `target` height keeping its
|
|
123
|
+
# content top-aligned (or at its `anchor`), then hands the extra room to whatever inside
|
|
124
|
+
# can absorb it -- a `stack` child carrying `extend "height"`
|
|
125
|
+
# itself (the first such one in a stack's column gets all of it, and
|
|
126
|
+
# the ranks below it move down, so e.g. a footer rank lines up
|
|
127
|
+
# across columns), reached through single-child wrappers such as a
|
|
128
|
+
# `boundary` around a `stack`.
|
|
129
|
+
def grow_height(node, target)
|
|
130
|
+
box = @boxes[node.id]
|
|
131
|
+
extra = target - box.height
|
|
132
|
+
return if extra <= 0
|
|
133
|
+
|
|
134
|
+
box.height = target
|
|
135
|
+
return if node.children.empty?
|
|
136
|
+
|
|
137
|
+
# An explicit `anchor` decides where the content sits; otherwise a
|
|
138
|
+
# stack (or a wrapper around one) hands the room to its
|
|
139
|
+
# `extend "height"` child, and anything else stays top-aligned.
|
|
140
|
+
delta = node.anchor_shift(Axis::HEIGHT, extra, default: :top)
|
|
141
|
+
return recenter_children(node, Axis::HEIGHT, delta) if delta.positive?
|
|
142
|
+
|
|
143
|
+
if node.layer?
|
|
144
|
+
fill_layer(node, extra)
|
|
145
|
+
elsif node.children.length == 1
|
|
146
|
+
inner = node.children.first
|
|
147
|
+
grow_top_aligned(inner, extra) if inner.stack? || inner.extend_height?
|
|
148
|
+
elsif node.stack?
|
|
149
|
+
absorb_in_stack(node, extra)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
116
153
|
private
|
|
117
154
|
|
|
155
|
+
# A grown `layer`'s `extend "height"` children fill the new height
|
|
156
|
+
# too (they were only matched to their tallest sibling before).
|
|
157
|
+
def fill_layer(node, extra)
|
|
158
|
+
room = @boxes[node.id].height - inset(node, Axis::HEIGHT)
|
|
159
|
+
node.children.select(&:extend_height?).each do |c|
|
|
160
|
+
grow = room - @boxes[c.id].height
|
|
161
|
+
grow_top_aligned(c, grow) if grow.positive? && extra.positive?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Grows a child by `extra` keeping its top edge where it is (box
|
|
166
|
+
# positions are centers, so the center moves down by half of it).
|
|
167
|
+
def grow_top_aligned(node, extra)
|
|
168
|
+
grow_height(node, @boxes[node.id].height + extra)
|
|
169
|
+
@boxes[node.id].y += extra / 2.0
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Gives `extra` to the first child of the `stack` `node` marked
|
|
173
|
+
# `extend "height"`, and shifts every later rank down by it.
|
|
174
|
+
def absorb_in_stack(node, extra)
|
|
175
|
+
ordered = node.children.sort_by { |c| @boxes[c.id].y }
|
|
176
|
+
index = ordered.index(&:extend_height?)
|
|
177
|
+
return unless index
|
|
178
|
+
|
|
179
|
+
grower = ordered[index]
|
|
180
|
+
grow_top_aligned(grower, extra)
|
|
181
|
+
ordered[(index + 1)..].each { |c| @boxes[c.id].y += extra }
|
|
182
|
+
end
|
|
183
|
+
|
|
118
184
|
# How much of a container's size on `axis` is reserved by its own
|
|
119
185
|
# padding (and, on the height axis, its title bar) rather than
|
|
120
186
|
# available to a single child filling it -- the node's own
|
|
121
187
|
# `layout_padding`/`layout_title_height` (nothing, for an anonymous
|
|
122
188
|
# `layer`/`stack`, since it renders no box to pad).
|
|
189
|
+
# The padding (plus title bar, on the height axis) before the content.
|
|
190
|
+
def leading_inset(node, axis)
|
|
191
|
+
padding = node.layout_padding(@theme)
|
|
192
|
+
axis == Axis::HEIGHT ? padding + node.layout_title_height(@theme) : padding
|
|
193
|
+
end
|
|
194
|
+
|
|
123
195
|
def inset(node, axis)
|
|
124
196
|
padding = 2 * node.layout_padding(@theme)
|
|
125
197
|
axis == Axis::HEIGHT ? padding + node.layout_title_height(@theme) : padding
|
|
@@ -74,7 +74,9 @@ module Archsight
|
|
|
74
74
|
def arrange_layer(group, children)
|
|
75
75
|
max_height = children.map { |c| @boxes[c.id].height }.max
|
|
76
76
|
children.each do |c|
|
|
77
|
-
|
|
77
|
+
next if c.leaf? || !c.extend_height?
|
|
78
|
+
|
|
79
|
+
Expander.new(@boxes, theme: @theme).grow_height(c, max_height)
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
gap = group.gap(@theme.sibling_gap)
|
|
@@ -43,6 +43,11 @@ module Archsight
|
|
|
43
43
|
# own attrs at graph-resolution time).
|
|
44
44
|
DataFlowBranch = Struct.new(:hops, :attrs, :line, keyword_init: true)
|
|
45
45
|
|
|
46
|
+
# `include "<path>" [{ namespace "cs" }]` -- another diagram spliced in at this position (see `Includes`).
|
|
47
|
+
# Parsed like any statement, replaced by the included statements before the graph is built, so nothing
|
|
48
|
+
# downstream sees it. `namespace`, if any, puts `<namespace>.` in front of every id the included diagram defines.
|
|
49
|
+
Include = Struct.new(:path, :namespace, :line, keyword_init: true)
|
|
50
|
+
|
|
46
51
|
# A top-level diagram-wide `key "value"` setting -- currently only
|
|
47
52
|
# `theme "compact"` (see `Theme`).
|
|
48
53
|
Setting = Struct.new(:key, :value, :line, keyword_init: true)
|
|
@@ -20,6 +20,8 @@ module Archsight
|
|
|
20
20
|
|
|
21
21
|
raise ParseError, "#{label} at line #{line}: columns must be a positive integer, got #{raw.inspect}" unless raw.match?(/\A[1-9]\d*\z/)
|
|
22
22
|
|
|
23
|
+
raise ParseError, "#{label} at line #{line}: columns cannot be combined with include (the included blocks are not known yet)" if children.any?(AST::Include)
|
|
24
|
+
|
|
23
25
|
blocks, edges = children.partition { |c| c.is_a?(AST::Block) }
|
|
24
26
|
count = [raw.to_i, blocks.length].min
|
|
25
27
|
return children if blocks.empty?
|
|
@@ -14,9 +14,11 @@ module Archsight
|
|
|
14
14
|
#
|
|
15
15
|
# Grammar (informal):
|
|
16
16
|
# program := statement*
|
|
17
|
-
# statement := block | dataflow | edge | setting
|
|
17
|
+
# statement := block | dataflow | edge | setting | include
|
|
18
|
+
# include := "include" STRING ["{" "namespace" STRING "}"] [";"]
|
|
19
|
+
# -- top level, or where a container may hold blocks and edges
|
|
18
20
|
# setting := ("theme" | "legend" | "ranks") STRING [";"] -- top level only
|
|
19
|
-
# block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge)* "}"
|
|
21
|
+
# block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge | include)* "}"
|
|
20
22
|
# -- the id is required except for ANONYMOUS_KEYWORDS
|
|
21
23
|
# dataflow := "dataflow" STRING "{" (hop | hop_group | attr)* "}"
|
|
22
24
|
# -- needs >= 2 hops (per branch, if a hop_group is present);
|
|
@@ -45,6 +47,7 @@ module Archsight
|
|
|
45
47
|
# `layer`/`stack` may be used without an id, purely as a layout hint
|
|
46
48
|
# (see AST::Block#anonymous) — a `group`/`boundary` always renders a
|
|
47
49
|
# visible box, so an unnamed one wouldn't make sense the same way.
|
|
50
|
+
NAMESPACE = /\A[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\z/
|
|
48
51
|
ANONYMOUS_KEYWORDS = %w[layer stack].freeze
|
|
49
52
|
SETTING_KEYWORDS = %w[theme legend ranks].freeze
|
|
50
53
|
EDGE_ARROWS = {
|
|
@@ -55,13 +58,16 @@ module Archsight
|
|
|
55
58
|
|
|
56
59
|
attr_reader :cursor
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
# `anon_scope` namespaces the ids of anonymous layers/stacks, so the statements of an included
|
|
62
|
+
# source (parsed on its own) never collide with those of the including one.
|
|
63
|
+
def self.parse(source, anon_scope: nil)
|
|
64
|
+
new(source, anon_scope: anon_scope).parse
|
|
60
65
|
end
|
|
61
66
|
|
|
62
|
-
def initialize(source)
|
|
67
|
+
def initialize(source, anon_scope: nil)
|
|
63
68
|
@cursor = TokenCursor.new(Lexer.new(source).tokenize)
|
|
64
69
|
@anon_counter = 0
|
|
70
|
+
@anon_scope = anon_scope
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
def parse
|
|
@@ -75,6 +81,8 @@ module Archsight
|
|
|
75
81
|
def statement
|
|
76
82
|
if check_block_keyword?
|
|
77
83
|
block
|
|
84
|
+
elsif include_directive?
|
|
85
|
+
include_statement
|
|
78
86
|
elsif cursor.check(:ident, "dataflow") && cursor.peek_type(1) == :string
|
|
79
87
|
DataflowParser.new(cursor).parse
|
|
80
88
|
elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
|
|
@@ -108,22 +116,16 @@ module Archsight
|
|
|
108
116
|
children = []
|
|
109
117
|
|
|
110
118
|
parse_attrs_until_rbrace(attrs, label, expected: "an attribute, a nested block, an edge") do
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
|
|
115
|
-
children << edge
|
|
116
|
-
true
|
|
117
|
-
else
|
|
118
|
-
false
|
|
119
|
-
end
|
|
119
|
+
child = container_child
|
|
120
|
+
children << child if child
|
|
121
|
+
child
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
cursor.expect(:rbrace, "expected '}' to close #{label}")
|
|
123
125
|
|
|
124
126
|
if LEAF_KEYWORDS.include?(kind_token.value) && !children.empty?
|
|
125
127
|
raise ParseError, "#{label} at line #{kind_token.line} cannot " \
|
|
126
|
-
"contain nested blocks or
|
|
128
|
+
"contain nested blocks, edges or includes (did you mean 'group'?)"
|
|
127
129
|
end
|
|
128
130
|
|
|
129
131
|
raise ParseError, "#{label} at line #{kind_token.line}: columns only applies to containers" if LEAF_KEYWORDS.include?(kind_token.value) && attrs.key?("columns")
|
|
@@ -133,6 +135,46 @@ module Archsight
|
|
|
133
135
|
AST::Block.new(kind: kind, id: id, anonymous: anonymous, attrs: attrs, children: children, line: kind_token.line)
|
|
134
136
|
end
|
|
135
137
|
|
|
138
|
+
# The nested block, edge or include at the cursor, nil if it is something else (an attribute)
|
|
139
|
+
def container_child
|
|
140
|
+
if check_block_keyword?
|
|
141
|
+
block
|
|
142
|
+
elsif include_directive?
|
|
143
|
+
include_statement
|
|
144
|
+
elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
|
|
145
|
+
edge
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# `include "<path>"` -- `include -> x` is still an edge, so a node may be named "include"
|
|
150
|
+
def include_directive?
|
|
151
|
+
cursor.check(:ident, "include") && cursor.peek_type(1) == :string
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def include_statement
|
|
155
|
+
keyword = cursor.advance
|
|
156
|
+
path = cursor.advance
|
|
157
|
+
namespace = include_options(path.value) if cursor.check(:lbrace)
|
|
158
|
+
cursor.consume(:semi)
|
|
159
|
+
AST::Include.new(path: path.value, namespace: namespace, line: keyword.line)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `{ namespace "cs" }` after an include: the only option
|
|
163
|
+
def include_options(path)
|
|
164
|
+
cursor.advance
|
|
165
|
+
options = {}
|
|
166
|
+
until cursor.check(:rbrace)
|
|
167
|
+
line = cursor.current.line
|
|
168
|
+
key, value = cursor.check(:ident) ? attr : cursor.expect(:ident, "expected 'namespace' in the options of include #{path.inspect}")
|
|
169
|
+
raise ParseError, "unknown option '#{key}' of include #{path.inspect} at line #{line} (only 'namespace')" unless key == "namespace"
|
|
170
|
+
raise ParseError, "the namespace #{value.inspect} of include #{path.inspect} at line #{line} must be a name like \"cs\" or \"a.b\"" unless value.match?(NAMESPACE)
|
|
171
|
+
|
|
172
|
+
store_attr(options, key, value, line, "include #{path.inspect}")
|
|
173
|
+
end
|
|
174
|
+
cursor.expect(:rbrace, "expected '}' to close the options of include #{path.inspect}")
|
|
175
|
+
options["namespace"]
|
|
176
|
+
end
|
|
177
|
+
|
|
136
178
|
# `theme "compact"`, `legend "right"` -- `theme -> x` is still an edge
|
|
137
179
|
# (an arrow, not a string, follows the id), so a node may still be
|
|
138
180
|
# named "theme" or "legend".
|
|
@@ -145,7 +187,7 @@ module Archsight
|
|
|
145
187
|
|
|
146
188
|
def next_anonymous_id
|
|
147
189
|
@anon_counter += 1
|
|
148
|
-
"__asd_anon_#{@anon_counter}"
|
|
190
|
+
"__asd_anon_#{@anon_scope}#{@anon_counter}"
|
|
149
191
|
end
|
|
150
192
|
|
|
151
193
|
# What a human should see identifying this block in an error message
|
data/lib/archsight/diagram.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "diagram/style/theme"
|
|
|
5
5
|
require_relative "diagram/parser/lexer"
|
|
6
6
|
require_relative "diagram/parser"
|
|
7
7
|
require_relative "diagram/parser/ast"
|
|
8
|
+
require_relative "diagram/includes"
|
|
8
9
|
require_relative "diagram/graph"
|
|
9
10
|
require_relative "diagram/resource_links"
|
|
10
11
|
require_relative "diagram/layout"
|
|
@@ -33,9 +34,14 @@ module Archsight
|
|
|
33
34
|
# `resolver` turns a node's `resource "..."` reference into a link (see
|
|
34
35
|
# `ResourceLinks`); `unresolved`, if an Array, collects the references it
|
|
35
36
|
# couldn't resolve.
|
|
37
|
+
# `includes` loads the diagrams that `include "<path>"` statements name (see `Includes`); without it
|
|
38
|
+
# a source that includes anything does not render. `warnings`, if an Array, collects what the includes left
|
|
39
|
+
# out (see `Includes`).
|
|
36
40
|
def self.render(source, relation_filter: Relations::DEFAULT_FILTER, profile: nil, style: nil, theme: nil, legend: nil, id_prefix: nil,
|
|
37
|
-
resolver: nil, unresolved: nil)
|
|
38
|
-
statements = time(profile, :parse)
|
|
41
|
+
resolver: nil, unresolved: nil, includes: nil, warnings: nil)
|
|
42
|
+
statements = time(profile, :parse) do
|
|
43
|
+
expand(source, includes: includes).tap { |expanded| warnings&.concat(expanded.warnings) }.statements
|
|
44
|
+
end
|
|
39
45
|
graph = time(profile, :graph) do
|
|
40
46
|
Graph.build(statements).tap { |g| ResourceLinks.apply(g, resolver, unresolved) }
|
|
41
47
|
end
|
|
@@ -47,8 +53,18 @@ module Archsight
|
|
|
47
53
|
# The `resource "..."` references of the nodes of a diagram source, in order and without duplicates. Parses and
|
|
48
54
|
# builds the graph but does not lay it out, so it is cheap enough to run over every diagram of a model.
|
|
49
55
|
# @raise [Archsight::Diagram::Error] if the source is not a valid diagram
|
|
50
|
-
def self.resource_references(source)
|
|
51
|
-
Graph.build(
|
|
56
|
+
def self.resource_references(source, includes: nil)
|
|
57
|
+
Graph.build(expand(source, includes: includes).statements).nodes_by_id.values.filter_map { |node| node.attrs["resource"] }.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Parses `source` and splices in the diagrams it includes.
|
|
61
|
+
# @return [Includes::Result] the statements, and the sources that were included (`{ path => source }`)
|
|
62
|
+
# @raise [Archsight::Diagram::Error] if the source or an included one is not a valid diagram
|
|
63
|
+
def self.expand(source, includes: nil)
|
|
64
|
+
statements = Parser.parse(source)
|
|
65
|
+
return Includes::Result.new(statements: statements, sources: {}, warnings: []) unless Includes.includes?(statements)
|
|
66
|
+
|
|
67
|
+
Includes.expand(statements, includes)
|
|
52
68
|
end
|
|
53
69
|
|
|
54
70
|
def self.time(profile, key)
|
|
@@ -146,12 +146,17 @@ module Archsight
|
|
|
146
146
|
# A diagram from asd source (fenced block, .asd file) as block XML
|
|
147
147
|
def diagram(source, stem)
|
|
148
148
|
stem ||= "#{@page_name}-diagram-#{@diagrams += 1}"
|
|
149
|
-
figure(Archsight::Diagram.render(source, resolver: @diagram_links), stem)
|
|
149
|
+
figure(Archsight::Diagram.render(source, resolver: @diagram_links, includes: diagram_includes), stem)
|
|
150
150
|
rescue Archsight::Diagram::Error => e
|
|
151
151
|
problem("diagram #{stem}: #{e.message}")
|
|
152
152
|
""
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
+
# `include "file://..."` / `archsight://...` in a diagram: the page's database, else only files of the resources directory
|
|
156
|
+
def diagram_includes
|
|
157
|
+
Helpers::DiagramIncludes.new(@database, resources_dir: @resources_dir)
|
|
158
|
+
end
|
|
159
|
+
|
|
155
160
|
# An image that must not sit inside a paragraph (a diagram), nil for ordinary images
|
|
156
161
|
def block_image(attr)
|
|
157
162
|
path = asset_path(attr["src"].to_s)
|
|
@@ -18,6 +18,7 @@ module Archsight
|
|
|
18
18
|
module DiagramBlocks
|
|
19
19
|
BLOCK = %r{<pre><code class="language-asd">(.*?)</code></pre>}m
|
|
20
20
|
CACHE_LIMIT = 256
|
|
21
|
+
INCLUDE = /\binclude\s+"/
|
|
21
22
|
|
|
22
23
|
@cache = {}
|
|
23
24
|
@cache_lock = Mutex.new
|
|
@@ -26,11 +27,11 @@ module Archsight
|
|
|
26
27
|
|
|
27
28
|
# @return [Array(String, Hash{String => String})] HTML with a placeholder per
|
|
28
29
|
# asd block, and the rendered replacement for each placeholder
|
|
29
|
-
def extract(html, resolver: nil)
|
|
30
|
+
def extract(html, resolver: nil, includes: nil)
|
|
30
31
|
diagrams = {}
|
|
31
32
|
replaced = html.gsub(BLOCK) do |block|
|
|
32
33
|
placeholder = "<!--asd-diagram:#{diagrams.size}-->"
|
|
33
|
-
diagrams[placeholder] = render_block(::CGI.unescapeHTML(Regexp.last_match(1)), block, resolver: resolver)
|
|
34
|
+
diagrams[placeholder] = render_block(::CGI.unescapeHTML(Regexp.last_match(1)), block, resolver: resolver, includes: includes)
|
|
34
35
|
placeholder
|
|
35
36
|
end
|
|
36
37
|
[replaced, diagrams]
|
|
@@ -44,9 +45,9 @@ module Archsight
|
|
|
44
45
|
|
|
45
46
|
# A whole diagram definition (the `architecture/diagram` annotation) as
|
|
46
47
|
# inline SVG, or the error box plus the source if it doesn't render.
|
|
47
|
-
def render_diagram(source, resolver: nil)
|
|
48
|
+
def render_diagram(source, resolver: nil, includes: nil)
|
|
48
49
|
escaped = ::Rack::Utils.escape_html(source)
|
|
49
|
-
render_block(source, %(<pre><code class="language-asd">#{escaped}</code></pre>), resolver: resolver)
|
|
50
|
+
render_block(source, %(<pre><code class="language-asd">#{escaped}</code></pre>), resolver: resolver, includes: includes)
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
# A diagram for the editor preview: the same SVG (and cache) as a rendered page, but a diagram that
|
|
@@ -54,8 +55,8 @@ module Archsight
|
|
|
54
55
|
# that repeats the source, which the editor already shows.
|
|
55
56
|
#
|
|
56
57
|
# @return [Hash] `{ html: "<figure ...>", error: nil }` or `{ html: nil, error: "message" }`
|
|
57
|
-
def preview(source, resolver: nil)
|
|
58
|
-
{ html: render_svg(source, resolver: resolver), error: nil }
|
|
58
|
+
def preview(source, resolver: nil, includes: nil)
|
|
59
|
+
{ html: render_svg(source, resolver: resolver, includes: includes), error: nil }
|
|
59
60
|
rescue Archsight::Diagram::Error => e
|
|
60
61
|
{ html: nil, error: e.message }
|
|
61
62
|
end
|
|
@@ -73,40 +74,54 @@ module Archsight
|
|
|
73
74
|
# `resource "..."` references resolve against the database, which can
|
|
74
75
|
# change under a cached render (a reload, an edit), so the outcome of
|
|
75
76
|
# each reference is part of the key.
|
|
76
|
-
def render_block(source, original, resolver: nil)
|
|
77
|
-
render_svg(source, resolver: resolver)
|
|
77
|
+
def render_block(source, original, resolver: nil, includes: nil)
|
|
78
|
+
render_svg(source, resolver: resolver, includes: includes)
|
|
78
79
|
rescue Archsight::Diagram::Error => e
|
|
79
80
|
message = ::Rack::Utils.escape_html(e.message)
|
|
80
81
|
%(<div class="asd-diagram-error"><p><strong>Diagram error:</strong> #{message}</p>#{original}</div>)
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
# @raise [Archsight::Diagram::Error] if the source does not render
|
|
84
|
-
def render_svg(source, resolver: nil)
|
|
85
|
-
key = fingerprint(source, resolver: resolver)
|
|
85
|
+
def render_svg(source, resolver: nil, includes: nil)
|
|
86
|
+
key = fingerprint(source, resolver: resolver, includes: includes)
|
|
86
87
|
cached(key) do
|
|
87
|
-
|
|
88
|
+
warnings = [] #: Array[String]
|
|
89
|
+
svg = Archsight::Diagram.render(source, id_prefix: "asd#{key}", resolver: resolver, includes: includes, warnings: warnings)
|
|
90
|
+
warnings.each { |message| warn "Diagram: #{message}" }
|
|
88
91
|
%(<figure class="asd-diagram">#{svg.sub(/\A<\?xml[^>]*\?>\s*/, "")}</figure>)
|
|
89
92
|
end
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
# The cache key of a diagram: its source plus how each `resource` reference resolves right now. It
|
|
93
|
-
# changes exactly when the rendered diagram would, so it is also a good ETag.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
# changes exactly when the rendered diagram would, so it is also a good ETag. The diagrams it includes
|
|
97
|
+
# are part of it: their sources, and how their `resource` references resolve.
|
|
98
|
+
def fingerprint(source, resolver: nil, includes: nil)
|
|
99
|
+
included = included_sources(source, includes)
|
|
100
|
+
::Digest::SHA256.hexdigest([source, included, resolutions([source, *included.values], resolver)].inspect)[0, 12]
|
|
96
101
|
end
|
|
97
102
|
|
|
98
103
|
# A diagram as a standalone SVG document (what the assets API serves for an .asd file), cached by
|
|
99
104
|
# fingerprint like the inline diagrams.
|
|
100
105
|
# @raise [Archsight::Diagram::Error] if the source does not render
|
|
101
|
-
def standalone_svg(source, resolver: nil)
|
|
102
|
-
key = fingerprint(source, resolver: resolver)
|
|
103
|
-
cached("svg-#{key}") { Archsight::Diagram.render(source, id_prefix: "asd#{key}", resolver: resolver) }
|
|
106
|
+
def standalone_svg(source, resolver: nil, includes: nil)
|
|
107
|
+
key = fingerprint(source, resolver: resolver, includes: includes)
|
|
108
|
+
cached("svg-#{key}") { Archsight::Diagram.render(source, id_prefix: "asd#{key}", resolver: resolver, includes: includes) }
|
|
104
109
|
end
|
|
105
110
|
|
|
106
|
-
|
|
111
|
+
# `{ path => source }` of the diagrams `source` includes, recursively; empty for a source that includes
|
|
112
|
+
# none (checked on the text first: most diagrams include nothing and need not be parsed twice)
|
|
113
|
+
# @raise [Archsight::Diagram::Error] if the source or an included one is not a valid diagram
|
|
114
|
+
def included_sources(source, includes)
|
|
115
|
+
return {} unless includes && source.match?(INCLUDE)
|
|
116
|
+
|
|
117
|
+
Archsight::Diagram.expand(source, includes: includes).sources
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def resolutions(sources, resolver)
|
|
107
121
|
return [] unless resolver
|
|
108
122
|
|
|
109
|
-
source.scan(/\bresource\s+"([^"]*)"/).flatten
|
|
123
|
+
references = sources.flat_map { |source| source.scan(/\bresource\s+"([^"]*)"/).flatten }
|
|
124
|
+
references.uniq.map { |reference| [reference, resolver.call(reference)] }
|
|
110
125
|
end
|
|
111
126
|
|
|
112
127
|
def cached(key)
|