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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +4 -0
  3. data/docs/configuration.md +5 -0
  4. data/docs/diagram.md +69 -0
  5. data/docs/index.md.erb +1 -0
  6. data/docs/modeling.md +3 -1
  7. data/docs/pdf-export.md +118 -0
  8. data/lib/archsight/analysis/typst_renderer.rb +25 -78
  9. data/lib/archsight/cli.rb +39 -1
  10. data/lib/archsight/database.rb +4 -3
  11. data/lib/archsight/diagram/cli.rb +11 -1
  12. data/lib/archsight/diagram/errors.rb +1 -0
  13. data/lib/archsight/diagram/graph/attributes.rb +3 -1
  14. data/lib/archsight/diagram/graph/node.rb +18 -1
  15. data/lib/archsight/diagram/includes.rb +168 -0
  16. data/lib/archsight/diagram/layout/expander.rb +75 -3
  17. data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
  18. data/lib/archsight/diagram/parser/ast.rb +5 -0
  19. data/lib/archsight/diagram/parser/table_layout.rb +2 -0
  20. data/lib/archsight/diagram/parser.rb +58 -16
  21. data/lib/archsight/diagram.rb +20 -4
  22. data/lib/archsight/export/confluence/storage.rb +6 -1
  23. data/lib/archsight/helpers/diagram_blocks.rb +34 -19
  24. data/lib/archsight/helpers/diagram_includes.rb +67 -0
  25. data/lib/archsight/helpers.rb +1 -0
  26. data/lib/archsight/linter.rb +10 -2
  27. data/lib/archsight/markdown.rb +17 -1
  28. data/lib/archsight/pdf/cache.rb +41 -0
  29. data/lib/archsight/pdf/compiler.rb +69 -0
  30. data/lib/archsight/pdf/dependency_graph.rb +127 -0
  31. data/lib/archsight/pdf/document.rb +227 -0
  32. data/lib/archsight/pdf/embeds.rb +134 -0
  33. data/lib/archsight/pdf/images.rb +94 -0
  34. data/lib/archsight/pdf/markdown.rb +217 -0
  35. data/lib/archsight/pdf/remote_image.rb +122 -0
  36. data/lib/archsight/pdf/resource_sheet.rb +129 -0
  37. data/lib/archsight/pdf/sample_document.rb +67 -0
  38. data/lib/archsight/pdf/tables.rb +72 -0
  39. data/lib/archsight/pdf/theme.rb +125 -0
  40. data/lib/archsight/pdf/typst/archsight.typ +152 -0
  41. data/lib/archsight/pdf/typst.rb +45 -0
  42. data/lib/archsight/pdf.rb +30 -0
  43. data/lib/archsight/references.rb +4 -3
  44. data/lib/archsight/resources/application_component.rb +10 -0
  45. data/lib/archsight/resources/application_service.rb +9 -0
  46. data/lib/archsight/resources/config.rb +147 -0
  47. data/lib/archsight/resources/technology_service.rb +9 -0
  48. data/lib/archsight/version.rb +1 -1
  49. data/lib/archsight/web/api/json_helpers.rb +3 -2
  50. data/lib/archsight/web/api/openapi/spec.yaml +239 -2
  51. data/lib/archsight/web/api/page_helpers.rb +1 -1
  52. data/lib/archsight/web/api/pdf_helpers.rb +46 -0
  53. data/lib/archsight/web/api/routes.rb +34 -3
  54. data/lib/archsight/web/application.rb +2 -1
  55. data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
  56. data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
  57. data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
  58. data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
  59. data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
  60. data/lib/archsight/web/public/vue/{InstanceRouter-Ca_C8izS.css → InstanceRouter-BCKbW-Wp.css} +1 -1
  61. data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
  62. data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
  63. data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
  64. data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
  65. data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
  66. data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
  67. data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
  68. data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
  69. data/lib/archsight/web/public/vue/{index-yiZbbvAs.js → index-BF4zUlWZ.js} +2 -2
  70. data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
  71. data/lib/archsight/web/public/vue.html +2 -2
  72. metadata +37 -17
  73. data/lib/archsight/web/public/vue/DocPage-C8Ysway-.js +0 -1
  74. data/lib/archsight/web/public/vue/EditorPage-pr2MR6_b.js +0 -35
  75. data/lib/archsight/web/public/vue/ErrorPage-FAkpW-hn.js +0 -2
  76. data/lib/archsight/web/public/vue/GraphView-liQJzFyo.js +0 -1
  77. data/lib/archsight/web/public/vue/HomePage-DYpzSXG8.js +0 -2
  78. data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
  79. data/lib/archsight/web/public/vue/KindList-CrgI4qDb.js +0 -1
  80. data/lib/archsight/web/public/vue/PageView-BIxT_t49.js +0 -1
  81. data/lib/archsight/web/public/vue/QueryError-ChdyV8B1.js +0 -1
  82. data/lib/archsight/web/public/vue/ResourceList-DdtaHY4k.js +0 -2
  83. data/lib/archsight/web/public/vue/SearchResults-BYRAcK_o.js +0 -1
  84. data/lib/archsight/web/public/vue/WikiPage-CgvRN6R2.js +0 -3
  85. 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
- center = own_target / 2.0
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, own_target, inherited: extend)
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 / 2.0) if extra.positive?
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
- @boxes[c.id].height = max_height if !c.leaf? && c.extend_height?
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
- def self.parse(source)
59
- new(source).parse
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
- if check_block_keyword?
112
- children << block
113
- true
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 edges (did you mean 'group'?)"
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
@@ -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) { Parser.parse(source) }
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(Parser.parse(source)).nodes_by_id.values.filter_map { |node| node.attrs["resource"] }.uniq
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
- svg = Archsight::Diagram.render(source, id_prefix: "asd#{key}", resolver: resolver)
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
- def fingerprint(source, resolver: nil)
95
- ::Digest::SHA256.hexdigest([source, resolutions(source, resolver)].inspect)[0, 12]
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
- def resolutions(source, resolver)
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.uniq.map { |reference| [reference, resolver.call(reference)] }
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)