archsight 0.3.0 → 0.3.1

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/computed_annotations.md +7 -5
  4. data/docs/licenses.md +1 -2
  5. data/docs/modeling.md +5 -1
  6. data/docs/pages.md +63 -6
  7. data/docs/search.md +17 -0
  8. data/lib/archsight/annotations/annotation.rb +5 -4
  9. data/lib/archsight/annotations/computed.rb +5 -1
  10. data/lib/archsight/annotations/relation_resolver.rb +96 -79
  11. data/lib/archsight/cli.rb +3 -2
  12. data/lib/archsight/database.rb +2 -1
  13. data/lib/archsight/documentation.rb +2 -1
  14. data/lib/archsight/export/confluence/exporter.rb +11 -2
  15. data/lib/archsight/export/confluence/storage.rb +99 -8
  16. data/lib/archsight/export/confluence/tables.rb +78 -0
  17. data/lib/archsight/helpers/fenced_blocks.rb +61 -0
  18. data/lib/archsight/helpers/requirements_blocks.rb +90 -0
  19. data/lib/archsight/helpers/view_blocks.rb +108 -0
  20. data/lib/archsight/helpers/wiki_links.rb +39 -5
  21. data/lib/archsight/helpers.rb +3 -0
  22. data/lib/archsight/import/handlers/go_grapher.rb +4 -1
  23. data/lib/archsight/import/handlers/go_module_parser.rb +4 -1
  24. data/lib/archsight/linter.rb +25 -3
  25. data/lib/archsight/mcp/base.rb +38 -0
  26. data/lib/archsight/requirements.rb +70 -0
  27. data/lib/archsight/resources/analysis.rb +2 -1
  28. data/lib/archsight/resources/application_component.rb +5 -3
  29. data/lib/archsight/resources/application_interface.rb +4 -2
  30. data/lib/archsight/resources/application_service.rb +4 -3
  31. data/lib/archsight/resources/base.rb +27 -12
  32. data/lib/archsight/resources/business_actor.rb +5 -3
  33. data/lib/archsight/resources/business_product.rb +4 -3
  34. data/lib/archsight/resources/business_requirement.rb +4 -3
  35. data/lib/archsight/resources/compliance_evidence.rb +4 -2
  36. data/lib/archsight/resources/data_object.rb +2 -1
  37. data/lib/archsight/resources/import.rb +5 -2
  38. data/lib/archsight/resources/page.rb +4 -2
  39. data/lib/archsight/resources/technology_artifact.rb +4 -3
  40. data/lib/archsight/resources/technology_node.rb +1 -1
  41. data/lib/archsight/resources/technology_service.rb +4 -0
  42. data/lib/archsight/resources/technology_system_software.rb +4 -0
  43. data/lib/archsight/resources/view.rb +2 -1
  44. data/lib/archsight/version.rb +1 -1
  45. data/lib/archsight/view_table.rb +102 -0
  46. data/lib/archsight/web/api/json_helpers.rb +7 -5
  47. data/lib/archsight/web/api/openapi/spec.yaml +135 -2
  48. data/lib/archsight/web/api/requirements_helpers.rb +26 -0
  49. data/lib/archsight/web/api/routes.rb +19 -0
  50. data/lib/archsight/web/application.rb +6 -1
  51. data/lib/archsight/web/public/vue/ApiDocsPage-D-cPRZCT.js +1 -0
  52. data/lib/archsight/web/public/vue/ApiDocsPage-DZ0fa5-h.css +1 -0
  53. data/lib/archsight/web/public/vue/{DocPage-uaT8CdFm.js → DocPage-DK6vNDbF.js} +1 -1
  54. data/lib/archsight/web/public/vue/EditorPage-BoJpQaVw.js +35 -0
  55. data/lib/archsight/web/public/vue/EditorPage-CbG4mc9T.css +1 -0
  56. data/lib/archsight/web/public/vue/ErrorPage-Ck9izEUS.css +1 -0
  57. data/lib/archsight/web/public/vue/ErrorPage-PGyjdtEf.js +2 -0
  58. data/lib/archsight/web/public/vue/GraphView-BLiKR4zP.js +1 -0
  59. data/lib/archsight/web/public/vue/GraphView-BvWAbUAl.css +1 -0
  60. data/lib/archsight/web/public/vue/HomePage-C0lR8i2C.js +2 -0
  61. data/lib/archsight/web/public/vue/InstanceRouter-60Tt3ZNM.css +1 -0
  62. data/lib/archsight/web/public/vue/InstanceRouter-D3W2jJHV.js +1 -0
  63. data/lib/archsight/web/public/vue/KindList-BlsaRBNO.js +1 -0
  64. data/lib/archsight/web/public/vue/PageView-9MgHtrgl.js +1 -0
  65. data/lib/archsight/web/public/vue/QueryError-3EqjpbyV.css +1 -0
  66. data/lib/archsight/web/public/vue/QueryError-D1FL1xgA.js +1 -0
  67. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +1 -0
  68. data/lib/archsight/web/public/vue/ResourceList-vkgFyeOY.js +2 -0
  69. data/lib/archsight/web/public/vue/SearchResults-Cl3O_OEr.js +1 -0
  70. data/lib/archsight/web/public/vue/SearchResults-DiW5XVYW.css +1 -0
  71. data/lib/archsight/web/public/vue/WikiPage-C-8SG67a.css +1 -0
  72. data/lib/archsight/web/public/vue/WikiPage-CeCQBTDS.js +13 -0
  73. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +1 -0
  74. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +1 -0
  75. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +1 -0
  76. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +1 -0
  77. data/lib/archsight/web/public/vue/index-D7m61Ahx.js +3 -0
  78. data/lib/archsight/web/public/vue/index-Dbx3MXWG.css +1 -0
  79. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +1 -0
  80. data/lib/archsight/web/public/vue/mermaid-BjEi5URd.js +3334 -0
  81. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +1 -0
  82. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +1 -0
  83. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +1 -0
  84. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +1 -0
  85. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +1 -0
  86. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +1 -0
  87. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +1 -0
  88. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +1 -0
  89. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +1 -0
  90. data/lib/archsight/web/public/vue/{useGraphviz-C71SdG-N.js → useGraphviz-DweKV7Kg.js} +1 -1
  91. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +1 -0
  92. data/lib/archsight/web/public/vue.html +3 -3
  93. data/lib/archsight.rb +2 -0
  94. metadata +49 -40
  95. data/lib/archsight/web/public/vue/ApiDocsPage-C0y953v0.css +0 -1
  96. data/lib/archsight/web/public/vue/ApiDocsPage-C_4tAWis.js +0 -1
  97. data/lib/archsight/web/public/vue/EditorPage-C557BJC-.js +0 -35
  98. data/lib/archsight/web/public/vue/EditorPage-df5N-p21.css +0 -1
  99. data/lib/archsight/web/public/vue/ErrorPage-Vdebmife.js +0 -2
  100. data/lib/archsight/web/public/vue/ErrorPage-uMDnfY5_.css +0 -1
  101. data/lib/archsight/web/public/vue/GraphView-BduUql2N.js +0 -1
  102. data/lib/archsight/web/public/vue/GraphView-Cj2V2stN.css +0 -1
  103. data/lib/archsight/web/public/vue/HomePage-BHUTg8Ap.js +0 -2
  104. data/lib/archsight/web/public/vue/InstanceRouter-1lSigA58.js +0 -1
  105. data/lib/archsight/web/public/vue/InstanceRouter-Di7f3Rya.css +0 -1
  106. data/lib/archsight/web/public/vue/KindList-BDZs0j6d.js +0 -1
  107. data/lib/archsight/web/public/vue/PageView-BYzJDwof.js +0 -1
  108. data/lib/archsight/web/public/vue/ResourceList-CnbhU9wm.js +0 -1
  109. data/lib/archsight/web/public/vue/ResourceList-xyBwu7fh.css +0 -1
  110. data/lib/archsight/web/public/vue/SearchResults-CNhf6VOx.js +0 -1
  111. data/lib/archsight/web/public/vue/SearchResults-DOHzqAy3.css +0 -1
  112. data/lib/archsight/web/public/vue/WikiPage-DbmWkM7W.js +0 -13
  113. data/lib/archsight/web/public/vue/WikiPage-GV67QmNJ.css +0 -1
  114. data/lib/archsight/web/public/vue/architecture-TIHT7OUA-Bf-MWFmn.js +0 -1
  115. data/lib/archsight/web/public/vue/cynefin-VYW2F7L2-CkKt8qqs.js +0 -1
  116. data/lib/archsight/web/public/vue/eventmodeling-45OFAUF4-NwSTYPHj.js +0 -1
  117. data/lib/archsight/web/public/vue/gitGraph-TEB2WS4Q-DLRegrRG.js +0 -1
  118. data/lib/archsight/web/public/vue/index-CyVWObLU.js +0 -3
  119. data/lib/archsight/web/public/vue/index-DtKeHT3S.css +0 -1
  120. data/lib/archsight/web/public/vue/info-DKCQHKI2-EV39NzoN.js +0 -1
  121. data/lib/archsight/web/public/vue/mermaid-BMkGfnhm.js +0 -3279
  122. data/lib/archsight/web/public/vue/packet-7NZHBO7P-USljV3MZ.js +0 -1
  123. data/lib/archsight/web/public/vue/pie-RZYD4A2V-DChciBW2.js +0 -1
  124. data/lib/archsight/web/public/vue/radar-I7S5WNFK-CgGJdW1t.js +0 -1
  125. data/lib/archsight/web/public/vue/railroad-3IZDKUUU-Dy43vF5c.js +0 -1
  126. data/lib/archsight/web/public/vue/railroad-abnf-AHOZXSZD-BhsTH-sE.js +0 -1
  127. data/lib/archsight/web/public/vue/railroad-ebnf-EBAXGLYW-BBTd_u0u.js +0 -1
  128. data/lib/archsight/web/public/vue/railroad-peg-LSFZ7HO6-BiNTEzf0.js +0 -1
  129. data/lib/archsight/web/public/vue/treeView-QDETBFTQ-BfnCcf-q.js +0 -1
  130. data/lib/archsight/web/public/vue/treemap-6X3UGDF4-BsAORhvk.js +0 -1
  131. data/lib/archsight/web/public/vue/wardley-OPB4EBWU-DQvnEhRj.js +0 -1
@@ -1,16 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
+ require "uri"
4
5
  require "digest"
5
6
  require "kramdown"
6
7
  require "kramdown-parser-gfm"
7
8
  require_relative "../../assets"
8
9
  require_relative "../../diagram"
9
10
  require_relative "../../helpers/macros"
11
+ require_relative "../../helpers/view_blocks"
12
+ require_relative "../../helpers/requirements_blocks"
13
+ require_relative "../../query"
14
+ require_relative "../../requirements"
10
15
  require_relative "diagram_links"
11
16
  require_relative "drawio"
12
17
  require_relative "page_url"
13
18
  require_relative "rasterizer"
19
+ require_relative "tables"
14
20
 
15
21
  module Archsight
16
22
  module Export
@@ -31,11 +37,18 @@ module Archsight
31
37
  def convert_codeblock(elem, _indent)
32
38
  language = elem.options[:lang].to_s
33
39
  return @options[:storage].diagram(elem.value, nil) if language == "asd"
40
+ return @options[:storage].view_block(elem.value) if language == "view"
41
+ return @options[:storage].requirements_block(elem.value) if language == "requirements"
34
42
 
35
43
  @options[:storage].code(elem.value, language)
36
44
  end
37
45
 
38
46
  def convert_p(elem, indent)
47
+ only = elem.children.length == 1 && elem.children.first.type == :text ? elem.children.first.value.strip : nil
48
+ if only && (table = @options[:storage].table_placeholder(only))
49
+ return table
50
+ end
51
+
39
52
  image = elem.children.reject { |c| c.type == :text && c.value.strip.empty? }
40
53
  if image.length == 1 && image.first.type == :img && (block = @options[:storage].block_image(image.first.attr))
41
54
  return "#{block}\n"
@@ -48,9 +61,15 @@ module Archsight
48
61
  @options[:storage].image(elem.attr["src"].to_s, elem.attr["alt"].to_s)
49
62
  end
50
63
 
51
- # A macro whose output is a link of its own (Jira) is shown as plain text inside a link: no link in a link
64
+ # A link to a page of Archsight becomes a link to its Confluence page, or just its text if it has none;
65
+ # a macro whose output is a link of its own (Jira) is shown as plain text inside a link: no link in a link
52
66
  def convert_a(elem, indent)
53
- @options[:storage].in_link { super }
67
+ storage = @options[:storage]
68
+ target = storage.link_target(elem.attr["href"].to_s)
69
+ return inner(elem, indent) unless target
70
+
71
+ elem.attr["href"] = target
72
+ storage.in_link { super }
54
73
  end
55
74
 
56
75
  def convert_text(elem, indent)
@@ -69,7 +88,8 @@ module Archsight
69
88
  }.freeze
70
89
  KNOWN_LANGUAGES = %w[actionscript3 applescript bash c# cpp css coldfusion delphi diff erlang groovy html/xml java javafx
71
90
  javascript perl php text powershell python ruby scala sql vb yaml].freeze
72
- PLACEHOLDER = /ARCHSIGHT(LINK|EMBED)(\d+)X/
91
+ PLACEHOLDER = /ARCHSIGHT(LINK|EMBED|TABLE)(\d+)X/
92
+ TABLE_PLACEHOLDER = /\AARCHSIGHTTABLE(\d+)X\z/
73
93
  CODE = /(^[ \t]*(?:```|~~~).*?^[ \t]*(?:```|~~~)[ \t]*$|`[^`\n]*`)/m
74
94
 
75
95
  # @param page_name [String] used to name generated attachments
@@ -101,13 +121,12 @@ module Archsight
101
121
 
102
122
  # @param markdown [String] body of the page
103
123
  # @param toc [Boolean] add a table of contents
104
- # @param header [String] storage format put between the banner and the body (the page properties)
124
+ # @param header [String] storage format of the page properties, shown top left next to the banner
105
125
  # @return [Converted]
106
126
  def convert(markdown, toc: false, header: "")
107
127
  document = Kramdown::Document.new(protect(markdown), input: "GFM", auto_ids: false, entity_output: :as_char, smart_quotes: %w[apos apos quot quot])
108
128
  html, = Converter.convert(document.root, document.options.merge(storage: self))
109
- body = banner + header + (toc ? %(<ac:structured-macro ac:name="toc" />\n) : "") + html
110
- Converted.new(body: body, attachments: @attachments, problems: @problems)
129
+ Converted.new(body: layout(header, banner, (toc ? %(<ac:structured-macro ac:name="toc" />\n) : "") + html), attachments: @attachments, problems: @problems)
111
130
  end
112
131
 
113
132
  # ---- called by the converter
@@ -149,11 +168,43 @@ module Archsight
149
168
 
150
169
  def restore(text)
151
170
  text.gsub(PLACEHOLDER) do
171
+ kind = Regexp.last_match(1)
152
172
  xml, plain = @placeholders.fetch(Regexp.last_match(2).to_i)
153
- @in_link && plain ? plain : xml
173
+ # a table that is not alone in its paragraph (see table_placeholder) is shown as the note
174
+ kind == "TABLE" || (@in_link && plain) ? plain : xml
154
175
  end
155
176
  end
156
177
 
178
+ # A ```view block as a table (the data of the moment of the export), block XML
179
+ def view_block(source)
180
+ table_xml("view block") { Tables.xml(ViewTable.from_block(database!, source)) }
181
+ end
182
+
183
+ # A ```requirements block as a table, block XML
184
+ def requirements_block(source)
185
+ table_xml("requirements block") do
186
+ Tables.xml(Archsight::Requirements.table(database!, Helpers::RequirementsBlocks.parse(source)), empty: "No requirements")
187
+ end
188
+ end
189
+
190
+ # The table of a paragraph that holds nothing but the placeholder of a `![[View/Name]]` (a table cannot sit
191
+ # inside a paragraph), nil for any other text
192
+ def table_placeholder(text)
193
+ index = text[TABLE_PLACEHOLDER, 1]
194
+ index && @placeholders.fetch(index.to_i).first
195
+ end
196
+
197
+ # Where a markdown link leads in Confluence. The links of a page point into Archsight (`/pages/<name>`,
198
+ # `/kinds/...`), which does not exist there: a page that has a Confluence page is linked to it, any other
199
+ # Archsight path is not a link.
200
+ # @return [String, nil] the href to use, nil for the text only
201
+ def link_target(href)
202
+ return href unless href.start_with?("/") && !href.start_with?("//")
203
+
204
+ page = archsight_page(href)
205
+ page && confluence_url(page)
206
+ end
207
+
157
208
  # Runs the block for the content of a link
158
209
  def in_link
159
210
  previous = @in_link
@@ -165,6 +216,14 @@ module Archsight
165
216
 
166
217
  private
167
218
 
219
+ # The page layout: the page properties top left, the hint that the page is generated top right (a narrow
220
+ # sidebar), then the content in a section of its own, full width
221
+ def layout(properties, banner, content)
222
+ cell = ->(xml) { %(<ac:layout-cell>#{xml.empty? ? "<p />" : xml}</ac:layout-cell>) }
223
+ %(<ac:layout><ac:layout-section ac:type="two_right_sidebar">#{cell.call(properties)}#{cell.call(banner)}</ac:layout-section>) +
224
+ %(<ac:layout-section ac:type="single">#{cell.call(content)}</ac:layout-section></ac:layout>)
225
+ end
226
+
168
227
  def banner
169
228
  %(<ac:structured-macro ac:name="info"><ac:rich-text-body><p>#{format(BANNER, source: h(@source))}</p></ac:rich-text-body></ac:structured-macro>\n)
170
229
  end
@@ -180,12 +239,35 @@ module Archsight
180
239
  xml = macro.confluence(value, Archsight::Helpers::Macros::Context.new(@database, nil))
181
240
  xml && placeholder("LINK", xml, plain: (macro.plain(value) if macro.respond_to?(:plain)))
182
241
  end
242
+ text = text.gsub(%r{^[ \t]*!\[\[View/([^\]|]+)\]\][ \t]*$}) { view_embed(Regexp.last_match(1).strip) || Regexp.last_match(0) }
183
243
  text = text.gsub(/!\[\[([^\]|]+)\]\]/) { placeholder("EMBED", embed_note(Regexp.last_match(1).strip)) }
184
244
  text.gsub(Archsight::Helpers::WikiLinks::PATTERN) do
185
245
  placeholder("LINK", wiki_link(Regexp.last_match(1).strip, Regexp.last_match(2)&.strip))
186
246
  end
187
247
  end
188
248
 
249
+ # A View on a line of its own becomes its table; an unknown View stays an embed (a note), the linter reports it.
250
+ # Next to other text of its paragraph the table cannot be placed and the note is shown (`plain`).
251
+ def view_embed(name)
252
+ view = @database&.instances_by_kind("View")&.[](name)
253
+ return nil unless view
254
+
255
+ xml = table_xml("view #{name}") { Tables.xml(ViewTable.from_view(@database, view)) }
256
+ placeholder("TABLE", xml, plain: embed_note("View/#{name}"))
257
+ end
258
+
259
+ # The XML of a table, or nothing and a problem (the page is not exported) if it cannot be built
260
+ def table_xml(what)
261
+ yield
262
+ rescue Helpers::ViewBlocks::Error, Helpers::RequirementsBlocks::Error, Archsight::Query::QueryError => e
263
+ problem("#{what}: #{e.message}")
264
+ ""
265
+ end
266
+
267
+ def database!
268
+ @database || raise(Helpers::ViewBlocks::Error, "the export has no database to read the data from")
269
+ end
270
+
189
271
  def placeholder(kind, xml, plain: nil)
190
272
  @placeholders << [xml, plain]
191
273
  "ARCHSIGHT#{kind}#{@placeholders.length - 1}X"
@@ -195,9 +277,18 @@ module Archsight
195
277
  "<em>#{h(reference)} (live content, shown in Archsight only)</em>"
196
278
  end
197
279
 
280
+ # The page an Archsight path (`/pages/<name>`, `/kinds/Page/instances/<name>`, optionally with #anchor or ?query) shows
281
+ def archsight_page(href)
282
+ path = href.split(/[#?]/, 2).first.to_s
283
+ name = path[%r{\A/pages/(.+)\z}, 1] || path[%r{\A/kinds/Page/instances/([^/]+)\z}, 1]
284
+ name && @wiki.page_for(URI.decode_uri_component(name))
285
+ rescue ArgumentError
286
+ nil
287
+ end
288
+
198
289
  def wiki_link(target, label)
199
290
  page = @wiki.page_for(target)
200
- text = label || page&.title || target
291
+ text = label || @wiki.label_for(target)
201
292
  url = page && confluence_url(page)
202
293
  url ? %(<a href="#{h(url)}">#{h(text)}</a>) : h(text)
203
294
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "kramdown"
5
+ require "kramdown-parser-gfm"
6
+ require_relative "../../view_table"
7
+ require_relative "../../helpers/macros"
8
+ require_relative "diagram_links"
9
+
10
+ module Archsight
11
+ module Export
12
+ module Confluence
13
+ # A ViewTable::Table (the result of a view or of the requirements of some resources) as a regular table in
14
+ # storage format. Views and requirements are live in Archsight; in Confluence they are the data of the moment
15
+ # of the export.
16
+ module Tables
17
+ # status of a requirement -> colour of the Confluence status macro (as the web UI colours them)
18
+ STATUS_COLOURS = { "implemented" => "green", "partial" => "yellow", "planned" => "blue" }.freeze
19
+
20
+ # priority of a requirement -> colour of the Confluence status macro
21
+ PRIORITY_COLOURS = { "must" => "red", "should" => "yellow", "may" => "grey" }.freeze
22
+
23
+ module_function
24
+
25
+ # @param empty [String] shown instead of the table when there are no rows
26
+ # @return [String] block XML
27
+ def xml(table, empty: "No resources found")
28
+ out = +""
29
+ out << %(<p><strong>#{h(table.title)}</strong> (#{table.total} #{table.total == 1 ? "item" : "items"})</p>\n) unless table.title.to_s.empty?
30
+ if table.rows.empty?
31
+ out << %(<p><em>#{h(empty)}</em></p>\n)
32
+ else
33
+ out << "<table><tbody>\n#{row(table.columns.map { |c| "<th>#{h(c)}</th>" })}"
34
+ table.rows.each { |cells| out << row(cells.map { |cell| "<td>#{cell_xml(cell)}</td>" }) }
35
+ out << "</tbody></table>\n"
36
+ end
37
+ out << %(<p><em>#{table.cut} more #{table.cut == 1 ? "row" : "rows"} not shown, see Archsight</em></p>\n) if table.cut.positive?
38
+ out
39
+ end
40
+
41
+ def row(cells) = "<tr>#{cells.join}</tr>\n"
42
+
43
+ def cell_xml(cell)
44
+ return cell.resources.map { |resource| resource_xml(resource) }.join("<br />") unless cell.resources.empty?
45
+
46
+ case cell.as
47
+ when :status then lozenge(cell.text, STATUS_COLOURS)
48
+ when :priority then cell.text.empty? ? "" : lozenge(cell.text, PRIORITY_COLOURS)
49
+ when :markdown then markdown_xml(cell.text)
50
+ else h(cell.text).gsub("\n", "<br />")
51
+ end
52
+ end
53
+
54
+ # A link where the resource has a page in Confluence, else its name
55
+ def resource_xml(resource)
56
+ url = DiagramLinks.confluence_url(resource)
57
+ url ? %(<a href="#{h(url)}">#{h(resource.name)}</a>) : h(resource.name)
58
+ end
59
+
60
+ def lozenge(text, colours)
61
+ Helpers::Macros::Status.confluence(Helpers::Macros::Status::Value.new(colours.fetch(text, "grey"), text))
62
+ end
63
+
64
+ # Inline markdown (a requirement's story): the HTML of kramdown without the paragraph around a single line.
65
+ # Raw HTML in the text is shown as text (it would not be well-formed storage XML); `<https://..>` links stay.
66
+ def markdown_xml(text)
67
+ return "" if text.to_s.strip.empty?
68
+
69
+ safe = text.gsub(%r{<(?!(?:https?://|mailto:))}, "&lt;")
70
+ html = Kramdown::Document.new(safe, input: "GFM", entity_output: :as_char, auto_ids: false).to_html.strip
71
+ html.match(%r{\A<p>((?:(?!</?p>).)*)</p>\z}m) ? Regexp.last_match(1) : html
72
+ end
73
+
74
+ def h(text) = ERB::Util.html_escape(text)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+ require "yaml"
6
+
7
+ module Archsight
8
+ module Helpers
9
+ # What the ```lang blocks of rendered markdown that become something else (ViewBlocks, RequirementsBlocks) have
10
+ # in common. Two steps, so what a block becomes stays out of the markdown post-processing (URL auto-linking,
11
+ # macros and `[[Name]]` links are plain-text passes over the whole HTML):
12
+ #
13
+ # html, blocks = FencedBlocks.extract(html, language: "view", prefix: "view-block") { |source, original| ... }
14
+ # ...further processing of html...
15
+ # html = FencedBlocks.restore(html, blocks, prefix: "view-block")
16
+ module FencedBlocks
17
+ module_function
18
+
19
+ # @yield [source, original] the block's source and its `<pre><code>` HTML; returns the HTML to put in its place
20
+ # @return [Array(String, Hash{String => String})] HTML with a placeholder per block, and the replacement for each
21
+ def extract(html, language:, prefix:)
22
+ blocks = {}
23
+ pattern = %r{<pre><code class="language-#{Regexp.escape(language)}">(.*?)</code></pre>}m
24
+ replaced = html.gsub(pattern) do |block|
25
+ placeholder = "<!--#{prefix}:#{blocks.size}-->"
26
+ blocks[placeholder] = yield(::CGI.unescapeHTML(Regexp.last_match(1)), block)
27
+ placeholder
28
+ end
29
+ [replaced, blocks]
30
+ end
31
+
32
+ def restore(html, blocks, prefix:)
33
+ return html if blocks.empty?
34
+
35
+ html.gsub(/<!--#{Regexp.escape(prefix)}:\d+-->/) { |placeholder| blocks.fetch(placeholder, placeholder) }
36
+ end
37
+
38
+ # Source of every block of that language in `markdown` (for the linter).
39
+ def sources(markdown, language:)
40
+ markdown.scan(/^[ \t]*(?:```|~~~)#{Regexp.escape(language)}[ \t]*\n(.*?)^[ \t]*(?:```|~~~)[ \t]*$/m).flatten
41
+ end
42
+
43
+ # `<div class="css-class"><p><strong>Label:</strong> message</p>original</div>`
44
+ def error_box(css_class, label, message, original)
45
+ %(<div class="#{css_class}"><p><strong>#{label}:</strong> #{::ERB::Util.html_escape(message)}</p>#{original}</div>)
46
+ end
47
+
48
+ # `<div class="css-class" data-a="..." ...>original</div>`, the values escaped
49
+ def placeholder(css_class, data, original)
50
+ attributes = data.map { |name, value| %( data-#{name}="#{::ERB::Util.html_escape(value)}") }.join
51
+ %(<div class="#{css_class}"#{attributes}>#{original}</div>)
52
+ end
53
+
54
+ # A YAML mapping, without aliases or arbitrary objects
55
+ # @raise [Psych::Exception]
56
+ def load_yaml(source)
57
+ YAML.safe_load(source, aliases: false)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "fenced_blocks"
5
+
6
+ module Archsight
7
+ module Helpers
8
+ # Turns ```requirements fenced blocks in rendered markdown into the table of business requirements of a
9
+ # selection of resources (see Archsight::Requirements):
10
+ #
11
+ # ```requirements
12
+ # title: Requirements of the backup services # optional
13
+ # of: 'ApplicationService: name =~ "Backup"' # required: query selecting the resources
14
+ # priority: must # optional: must, should, may (one or a list)
15
+ # status: [implemented, partial] # optional: implemented, partial, planned
16
+ # ```
17
+ #
18
+ # Like views and embeds, rendering never runs the query: the block becomes a placeholder carrying the filter and
19
+ # the frontend asks the API (`GET /api/v1/requirements`). The source stays inside the placeholder for consumers
20
+ # that do not run the frontend (API, MCP). Extract and restore like DiagramBlocks, see FencedBlocks.
21
+ module RequirementsBlocks
22
+ KEYS = %w[title of priority status].freeze
23
+
24
+ class Error < StandardError; end
25
+
26
+ module_function
27
+
28
+ def extract(html)
29
+ FencedBlocks.extract(html, language: "requirements", prefix: "requirements-block") { |source, original| render_block(source, original) }
30
+ end
31
+
32
+ def restore(html, blocks)
33
+ FencedBlocks.restore(html, blocks, prefix: "requirements-block")
34
+ end
35
+
36
+ # Source of every ```requirements block in `markdown` (for the linter).
37
+ def sources(markdown)
38
+ FencedBlocks.sources(markdown, language: "requirements")
39
+ end
40
+
41
+ # The filter a block describes.
42
+ # @return [Hash] `{ title:, of:, priority: [String], status: [String] }`
43
+ # @raise [Error] if the block is not a valid filter
44
+ def parse(source)
45
+ doc = load(source)
46
+ raise Error, "expected a mapping with `of`, got #{doc.class.name.downcase}" unless doc.is_a?(Hash)
47
+
48
+ unknown = doc.keys.map(&:to_s) - KEYS
49
+ raise Error, "unknown key #{unknown.first.inspect}, known are #{KEYS.join(", ")}" unless unknown.empty?
50
+
51
+ of = doc["of"].to_s.strip
52
+ raise Error, "`of` is missing: the query selecting the resources" if of.empty?
53
+
54
+ check_query(of)
55
+ { title: doc["title"].to_s, of: of,
56
+ priority: values(doc["priority"], Archsight::Requirements::PRIORITIES, "priority"),
57
+ status: values(doc["status"], Archsight::Requirements::STATUSES.values, "status") }
58
+ end
59
+
60
+ def render_block(source, original)
61
+ spec = parse(source)
62
+ data = { title: spec[:title], of: spec[:of], priority: spec[:priority].join(","), status: spec[:status].join(",") }
63
+ FencedBlocks.placeholder("requirements-embed", data, original)
64
+ rescue Error => e
65
+ FencedBlocks.error_box("requirements-block-error", "Requirements error", e.message, original)
66
+ end
67
+
68
+ def load(source)
69
+ FencedBlocks.load_yaml(source)
70
+ rescue Psych::Exception => e
71
+ raise Error, "invalid YAML: #{e.message}"
72
+ end
73
+
74
+ def check_query(query)
75
+ Archsight::Query.parse(query)
76
+ rescue Archsight::Query::QueryError => e
77
+ raise Error, "invalid query: #{e.message}"
78
+ end
79
+
80
+ # A scalar or a list, each value one of `allowed`
81
+ def values(value, allowed, name)
82
+ list = Array(value).map { |v| v.to_s.strip }.reject(&:empty?)
83
+ invalid = list - allowed
84
+ raise Error, "#{name} must be #{allowed.join(", ")}, not #{invalid.first.inspect}" unless invalid.empty?
85
+
86
+ list
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "fenced_blocks"
5
+
6
+ module Archsight
7
+ module Helpers
8
+ # Turns ```view fenced blocks in rendered markdown into inline views: a View defined in place, written as the
9
+ # View resource itself, shown like an embedded `![[View/Name]]`:
10
+ #
11
+ # ```view
12
+ # kind: View
13
+ # metadata:
14
+ # name: Services without backup # the title, optional
15
+ # annotations:
16
+ # view/query: 'ApplicationService: backup/mode == "none"'
17
+ # view/fields: name, @owner
18
+ # ```
19
+ #
20
+ # Like embeds, rendering never runs the query: the block becomes a placeholder carrying the spec, the frontend
21
+ # runs the query. The source stays inside the placeholder for consumers that do not run the frontend (API, MCP).
22
+ #
23
+ # Two steps, like DiagramBlocks, so the query text stays out of the markdown post-processing (URL auto-linking,
24
+ # macros and `[[Name]]` links are plain-text passes over the whole HTML):
25
+ #
26
+ # html, blocks = ViewBlocks.extract(html) # blocks -> placeholders
27
+ # ...further processing of html...
28
+ # html = ViewBlocks.restore(html, blocks) # placeholders -> view placeholders / error boxes
29
+ module ViewBlocks
30
+ TYPES = %w[list:name list:name+kind].freeze
31
+ KEYS = %w[view/query view/fields view/sort view/type].freeze
32
+
33
+ class Error < StandardError; end
34
+
35
+ module_function
36
+
37
+ # @return [Array(String, Hash{String => String})] HTML with a placeholder per view block, and the rendered
38
+ # replacement for each placeholder
39
+ def extract(html)
40
+ FencedBlocks.extract(html, language: "view", prefix: "view-block") { |source, original| render_block(source, original) }
41
+ end
42
+
43
+ def restore(html, blocks)
44
+ FencedBlocks.restore(html, blocks, prefix: "view-block")
45
+ end
46
+
47
+ # Source of every ```view block in `markdown` (for the linter).
48
+ def sources(markdown)
49
+ FencedBlocks.sources(markdown, language: "view")
50
+ end
51
+
52
+ # The View a block describes.
53
+ # @return [Hash] `{ title:, query:, fields: [String], sort: [String], type: String }`
54
+ # @raise [Error] if the block is not a valid View
55
+ def parse(source)
56
+ doc = load(source)
57
+ raise Error, "expected a View resource (a YAML mapping), got #{doc.class.name.downcase}" unless doc.is_a?(Hash)
58
+ raise Error, "kind must be View, not #{doc["kind"].inspect}" if doc.key?("kind") && doc["kind"] != "View"
59
+
60
+ annotations = annotations(doc)
61
+ query = annotations["view/query"].to_s.strip
62
+ raise Error, "view/query is missing" if query.empty?
63
+
64
+ check_query(query)
65
+ type = annotations["view/type"]&.to_s || TYPES.last
66
+ raise Error, "view/type must be one of #{TYPES.join(", ")}, not #{type.inspect}" unless TYPES.include?(type)
67
+
68
+ { title: doc.dig("metadata", "name").to_s, query: query, type: type,
69
+ fields: list(annotations["view/fields"]), sort: list(annotations["view/sort"]) }
70
+ end
71
+
72
+ def render_block(source, original)
73
+ spec = parse(source)
74
+ data = { title: spec[:title], query: spec[:query], fields: spec[:fields].join(","), sort: spec[:sort].join(","), type: spec[:type] }
75
+ FencedBlocks.placeholder("view-embed", data, original)
76
+ rescue Error => e
77
+ FencedBlocks.error_box("view-block-error", "View error", e.message, original)
78
+ end
79
+
80
+ def load(source)
81
+ FencedBlocks.load_yaml(source)
82
+ rescue Psych::Exception => e
83
+ raise Error, "invalid YAML: #{e.message}"
84
+ end
85
+
86
+ def annotations(doc)
87
+ annotations = doc.dig("metadata", "annotations")
88
+ raise Error, "metadata.annotations must be a mapping" unless annotations.nil? || annotations.is_a?(Hash)
89
+
90
+ annotations ||= {}
91
+ unknown = annotations.keys.map(&:to_s).select { |key| key.start_with?("view/") } - KEYS
92
+ raise Error, "unknown annotation #{unknown.first.inspect}, a view knows #{KEYS.join(", ")}" unless unknown.empty?
93
+
94
+ annotations.transform_keys(&:to_s)
95
+ end
96
+
97
+ def check_query(query)
98
+ Archsight::Query.parse(query)
99
+ rescue Archsight::Query::QueryError => e
100
+ raise Error, "invalid query: #{e.message}"
101
+ end
102
+
103
+ def list(value)
104
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -11,6 +11,7 @@ module Archsight
11
11
  # render as a `broken-link` span.
12
12
  class WikiLinks
13
13
  PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/
14
+ HOVER_LENGTH = 200
14
15
 
15
16
  def initialize(database, resolver: ResourceResolver.new(database))
16
17
  @database = database
@@ -43,6 +44,26 @@ module Archsight
43
44
  find_page(target)
44
45
  end
45
46
 
47
+ # The text of a link without an explicit label: a page by its title, a resource named `Kind/Name` by its name
48
+ # (the kind is in the hover text), anything else as written (so a broken reference shows what must be fixed)
49
+ def label_for(target)
50
+ page = find_page(target)
51
+ return page.title if page
52
+
53
+ found = target.include?("/") ? @resolver.find(target) : nil
54
+ found.is_a?(Array) ? found.last : target
55
+ end
56
+
57
+ # The page or resource a target names, nil if it names none or is ambiguous
58
+ def target_for(target)
59
+ page = find_page(target)
60
+ return page if page
61
+
62
+ found = @resolver.find(target)
63
+ found = find_partial(target) || found if found == :missing && !target.include?("/")
64
+ found.is_a?(Array) ? @database.instances_by_kind(found.first)[found.last] : nil
65
+ end
66
+
46
67
  # Page path for a page name, nil if there is no such page
47
68
  def page_path(page)
48
69
  "/pages/#{ERB::Util.url_encode(page.name)}"
@@ -55,19 +76,32 @@ module Archsight
55
76
  target = ::Regexp.last_match(1).strip
56
77
  explicit_label = ::Regexp.last_match(2)&.strip
57
78
  href = resolve(target)
58
- label = explicit_label || default_label(target)
79
+ label = explicit_label || label_for(target)
59
80
  text = ERB::Util.html_escape(label)
60
81
  if href.is_a?(String)
61
- %(<a href="#{ERB::Util.html_escape(href)}">#{text}</a>)
82
+ %(<a href="#{ERB::Util.html_escape(href)}"#{hover(target_for(target))}>#{text}</a>)
62
83
  else
63
84
  %(<span class="broken-link" title="#{href == :ambiguous ? "Ambiguous reference" : "Resource not found"}">#{text}</span>)
64
85
  end
65
86
  end
66
87
  end
67
88
 
68
- # Pages are shown by title, everything else by the text as written
69
- def default_label(target)
70
- find_page(target)&.title || target
89
+ # `title` attribute of a link: the kind and the first line of the description (the status of a page)
90
+ def hover(instance)
91
+ return "" unless instance
92
+
93
+ kind = instance.class.name.split("::").last
94
+ detail = kind == "Page" ? instance.annotations["page/status"] : plain_line(instance.annotations["architecture/description"])
95
+ text = [kind, detail].map(&:to_s).reject(&:empty?).join("\n")
96
+ %( title="#{ERB::Util.html_escape(text)}")
97
+ end
98
+
99
+ # The first line of a markdown text as plain text, at most HOVER_LENGTH characters
100
+ def plain_line(markdown)
101
+ line = markdown.to_s.lines.map(&:strip).find { |l| !l.empty? }.to_s
102
+ line = line.gsub(%r{!?\[\[([^\]|]+/)?([^\]|]+)(?:\|([^\]]+))?\]\]}) { Regexp.last_match(3) || Regexp.last_match(2) }
103
+ .gsub(/!?\[([^\]]*)\]\([^)]*\)/, '\1').gsub(/\A[#>*\-\s]+/, "").delete("`*_")
104
+ line.length > HOVER_LENGTH ? "#{line[0, HOVER_LENGTH - 1].rstrip}…" : line
71
105
  end
72
106
 
73
107
  def find_page(target)
@@ -3,6 +3,9 @@
3
3
  require_relative "helpers/formatting"
4
4
  require_relative "helpers/analysis_renderer"
5
5
  require_relative "helpers/diagram_blocks"
6
+ require_relative "helpers/fenced_blocks"
7
+ require_relative "helpers/view_blocks"
8
+ require_relative "helpers/requirements_blocks"
6
9
  require_relative "helpers/resource_resolver"
7
10
  require_relative "helpers/embeds"
8
11
  require_relative "helpers/wiki_links"
@@ -55,6 +55,9 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
55
55
  output = +""
56
56
 
57
57
  modules.each do |rel_dir, mod_name|
58
+ comp_name = component_name(mod_name)
59
+ next unless comp_name
60
+
58
61
  mod_dir = rel_dir == "." ? path : File.join(path, rel_dir)
59
62
  specs = detect_openapi_specs(mod_dir)
60
63
  existing_interfaces = database&.instances_by_kind("ApplicationInterface") || {}
@@ -69,7 +72,7 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
69
72
  # is expected to keep and refine, not auto-regenerated on each run.
70
73
  component = untracked_resource_yaml(
71
74
  kind: "ApplicationComponent",
72
- name: component_name(mod_name),
75
+ name: comp_name,
73
76
  spec: comp_spec
74
77
  )
75
78
  output << YAML.dump(component)
@@ -133,9 +133,12 @@ module Archsight::Import::Handlers::GoModuleParser
133
133
  # Convert a Go module path to an ApplicationComponent name.
134
134
  # Strips the SCM host segment and joins remaining path segments with ":".
135
135
  # "github.com/example-org/billing-service/pkg" → "example-org:billing-service:pkg"
136
+ # A module path without a host ("module foo") has nothing left after stripping
137
+ # it and gets no component, rather than one with an empty name.
138
+ # @return [String, nil]
136
139
  def component_name(mod_name)
137
140
  parts = mod_name.split("/")
138
141
  parts.shift
139
- parts.join(":")
142
+ parts.join(":") unless parts.empty?
140
143
  end
141
144
  end