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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typst"
4
+
5
+ module Archsight
6
+ module Pdf
7
+ # The facts of a resource that is not a page, as Typst markup: its properties (annotations) and its relations in
8
+ # both directions. Structure comes from alignment and light rules, not from boxes: a muted label column, rows
9
+ # divided by hairlines, related annotations kept together, and the resources of a relation flowing in columns
10
+ # instead of one long comma-separated line.
11
+ class ResourceSheet
12
+ SKIP = %w[architecture/description architecture/diagram architecture/tags].freeze
13
+ LONG = 400 # characters; longer single-line values still sit in the table, wrapped, up to MAX
14
+ MAX = 2000
15
+ RULE = "0.4pt + theme.muted.lighten(75%)" # as the rule under the rows of every other table
16
+ NAMES_IN_COLUMNS = 4 # from this many resources a relation lists them in two columns
17
+
18
+ # @param markdown [#call] `(String) -> Typst`, renders markdown the way the document does
19
+ def initialize(resource, markdown:)
20
+ @resource = resource
21
+ @markdown = markdown
22
+ end
23
+
24
+ # @return [Array<String>] the sections (heading and content) of the properties, none if there are none
25
+ def properties
26
+ short, long = annotations.partition { |key, value| table_value?(key, value) }
27
+ parts = []
28
+ parts << "#heading(level: 1)[#{Typst.str("Properties")}]\n\n#{property_table(short)}" unless short.empty?
29
+ long.each { |key, value| parts << long_value(key, value) }
30
+ parts
31
+ end
32
+
33
+ # @return [Array<String>] the sections of the outgoing and the incoming relations
34
+ def relations
35
+ out = outgoing
36
+ incoming = self.incoming
37
+ return [] if out.empty? && incoming.empty?
38
+
39
+ parts = ["#heading(level: 1)[#{Typst.str("Relations")}]"]
40
+ parts << "#heading(level: 2)[#{Typst.str("Outgoing")}]\n\n#{relation_table(out)}" unless out.empty?
41
+ parts << "#heading(level: 2)[#{Typst.str("Incoming")}]\n\n#{caption("Resources that point to this one; the relation is read from their side.")}\n\n#{relation_table(incoming)}" unless incoming.empty?
42
+ parts
43
+ end
44
+
45
+ # `dependsOn` -> "depends on"
46
+ def self.humanize(verb)
47
+ verb.to_s.gsub(/([a-z0-9])([A-Z])/, '\1 \2').downcase
48
+ end
49
+
50
+ private
51
+
52
+ def klass = @resource.class
53
+
54
+ def annotations
55
+ @resource.annotations.reject { |key, value| SKIP.include?(key) || value.to_s.strip.empty? }
56
+ end
57
+
58
+ # Multi-line and markdown values are text, not table cells
59
+ def table_value?(key, value)
60
+ !value.to_s.include?("\n") && !%w[markdown multiline].include?(klass.annotation_format(key).to_s)
61
+ end
62
+
63
+ def display(key, value)
64
+ text = value.to_s
65
+ text = text.split(",").map(&:strip).reject(&:empty?).join(", ") if klass.annotation_matching(key)&.list?
66
+ text.length > MAX ? "#{text[0, MAX]}…" : text
67
+ end
68
+
69
+ # Annotations of one namespace (`git/...`, `lifecycle/...`) stay together, separated from the next by a gap
70
+ def property_table(pairs)
71
+ grouped = pairs.group_by { |key, _| key.split("/").first }.values
72
+ cells = grouped.each_with_index.flat_map do |group, index|
73
+ gap = index.zero? ? [] : ["table.cell(colspan: 2, stroke: none, inset: 3pt)[]"]
74
+ gap + group.flat_map do |key, value|
75
+ ["[#text(fill: theme.muted)[#{Typst.str(klass.annotation_title(key))}]]", "[#{Typst.str(display(key, value))}]"]
76
+ end
77
+ end
78
+ "#table(columns: (30%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
79
+ end
80
+
81
+ def long_value(key, value)
82
+ body = klass.annotation_format(key).to_s == "markdown" ? @markdown.call(value.to_s) : multiline(value.to_s)
83
+ "#heading(level: 2)[#{Typst.str(klass.annotation_title(key))}]\n\n#{body}"
84
+ end
85
+
86
+ def multiline(text)
87
+ text.lines.map { |line| Typst.str(line.chomp) }.join("#linebreak()")
88
+ end
89
+
90
+ # { verb => { kind => [instances] } }
91
+ def outgoing = @resource.relations_grouped
92
+
93
+ # incoming comes as { kind => { verb => [instances] } }, shown like the outgoing ones
94
+ def incoming
95
+ @resource.references_grouped.each_with_object({}) do |(kind, verbs), by_verb|
96
+ verbs.each { |verb, instances| (by_verb[verb] ||= {})[kind] = instances }
97
+ end.sort.to_h
98
+ end
99
+
100
+ # The relation (said once for all of its kinds), the kind, and the names of the resources in one or two columns
101
+ def relation_table(by_verb)
102
+ cells = by_verb.flat_map do |verb, kinds|
103
+ kinds.each_with_index.flat_map do |(kind, instances), index|
104
+ row = []
105
+ row << "table.cell(rowspan: #{kinds.size})[#text(weight: \"semibold\")[#{Typst.str(self.class.humanize(verb))}]]" if index.zero?
106
+ row << "[#pad(right: 8pt, text(fill: theme.muted)[#{Typst.str(breakable(kind))}])]"
107
+ row << "[#{names(instances)}]"
108
+ end
109
+ end
110
+ "#table(columns: (17%, 24%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
111
+ end
112
+
113
+ def names(instances)
114
+ list = instances.map(&:name).uniq.sort.map { |name| "[#{Typst.str(breakable(name))}]" }
115
+ columns = list.size >= NAMES_IN_COLUMNS ? "1fr, 1fr" : "1fr"
116
+ "#grid(columns: (#{columns},), column-gutter: 10pt, row-gutter: 4pt, #{list.join(", ")})"
117
+ end
118
+
119
+ # Names have no spaces: allow a line break after each `:` and between camel-case words (no visible change)
120
+ def breakable(name)
121
+ name.gsub(":", ":\u200B").gsub(/([a-z0-9])([A-Z])/, "\\1\u200B\\2")
122
+ end
123
+
124
+ def caption(text)
125
+ "#text(size: 8.5pt, fill: theme.muted)[#{Typst.str(text)}]"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "document"
4
+
5
+ module Archsight
6
+ module Pdf
7
+ # A fixed sample document in the look of one pdf Config, to see what the settings do without exporting a real page:
8
+ # a cover page and a page with the elements a document is made of (headings, text, list, quote, table with
9
+ # status, code). The content is neutral on purpose; only the Config shapes the result. The "resource" of the document
10
+ # is the Config (an Archsight::Resources::Config of type pdf).
11
+ class SampleDocument < Document
12
+ PARTS = %w[cover page].freeze
13
+ TITLE = "Sample document"
14
+ BODY = <<~MD
15
+ ## Summary
16
+
17
+ This page shows how headings, text, lists and tables look with the settings of the Config. It is a fixed sample,
18
+ not one of your documents, so every change of a colour, a font or the logo is visible at once.
19
+
20
+ > A quote stands out from the text around it and is set in the same typeface.
21
+
22
+ ### What a document contains
23
+
24
+ - Text with **strong** and *emphasised* words, `inline code` and a [link](https://example.com)
25
+ - Lists, tables, diagrams and code, set in the fonts of the Config
26
+ - Status badges such as {status:green Active} and {status:yellow Planned}
27
+
28
+ ## Components
29
+
30
+ | Component | Owner | Status |
31
+ |:----------|:------|:-------|
32
+ | Billing API | Payments | {status:green Active} |
33
+ | Export service | Data | {status:yellow Deprecated} |
34
+ | Legacy gateway | Platform | {status:red End of life} |
35
+
36
+ ### Interface
37
+
38
+ ```ruby
39
+ def total(items)
40
+ items.sum(&:price)
41
+ end
42
+ ```
43
+ MD
44
+
45
+ # @param part [String, nil] `cover` or `page` for just that page (a document of one page, so a viewer needs no
46
+ # navigation), else both
47
+ def initialize(database, config, part: nil, **)
48
+ @part = part if PARTS.include?(part)
49
+ super(database, config, **)
50
+ end
51
+
52
+ def filename = "#{["preview", super.delete_suffix(".pdf"), @part].compact.join("-")}.pdf"
53
+
54
+ private
55
+
56
+ def theme_for(_template) = Theme.new(@resource, resources_dir: @resources_dir)
57
+ def title_for = TITLE
58
+ def tag_list = @part == "page" ? [] : %w[pdf:cover]
59
+ def body(images) = @part == "cover" ? "" : markdown(images).render(BODY)
60
+ def doc_id = "SAMPLE-001"
61
+ def version = "1.0"
62
+ def owner = "Alex Example"
63
+ def kind = "Sample"
64
+ def classification = @theme.classification
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typst"
4
+ require_relative "markdown"
5
+ require_relative "../view_table"
6
+ require_relative "../export/confluence/tables"
7
+
8
+ module Archsight
9
+ module Pdf
10
+ # A ViewTable::Table (the result of a view, of requirements or of an analysis) as a Typst table. Views are live
11
+ # in Archsight; in the PDF they are the data of the moment of the export.
12
+ module Tables
13
+ # A column whose longest text is longer than this shares the width (`fr`), shorter ones take what they need
14
+ LONG = 30
15
+ SMALL_FONT_FROM = 4 # columns
16
+ Confluence = Export::Confluence::Tables
17
+
18
+ module_function
19
+
20
+ # @param table [Archsight::ViewTable::Table]
21
+ # @param markdown [#call] Typst markup of a markdown text, for the cells of `as: :markdown`
22
+ # @param empty [String] shown instead of the table when there are no rows
23
+ # @return [String] Typst markup
24
+ def typst(table, markdown:, empty: "No results")
25
+ parts = []
26
+ parts << heading(table) unless table.title.to_s.empty?
27
+ parts << (table.rows.empty? ? Typst.note(empty, fill: "#f2f2f2") : grid(table, markdown))
28
+ parts << "#emph[#{Typst.str("#{table.cut} more #{table.cut == 1 ? "row" : "rows"} not shown, see Archsight")}]" if table.cut.positive?
29
+ parts.join("\n\n")
30
+ end
31
+
32
+ def heading(table)
33
+ count = "(#{table.total} #{table.total == 1 ? "item" : "items"})"
34
+ "#block(sticky: true)[#strong[#{Typst.str(table.title)}] #text(fill: luma(120))[#{Typst.str(count)}]]"
35
+ end
36
+
37
+ def grid(table, markdown)
38
+ head = table.columns.map { |c| "[#text(weight: \"semibold\")[#{Typst.str(c)}]]" }
39
+ cells = table.rows.flat_map { |row| row.map { |cell| "[#{cell(cell, markdown)}]" } }
40
+ out = "#table(columns: (#{widths(table).join(", ")},), table.header(#{head.join(", ")}), #{cells.join(", ")})"
41
+ table.columns.size >= SMALL_FONT_FROM ? "#[#set text(size: 8.5pt)\n#{out}]" : out
42
+ end
43
+
44
+ # `auto` for short columns, a share of the rest for long ones, so wide tables wrap instead of running off the page
45
+ def widths(table)
46
+ table.columns.each_index.map do |i|
47
+ longest = [table.columns[i].length, *table.rows.map { |row| row[i] ? text_of(row[i]).length : 0 }].max
48
+ longest > LONG ? "#{(longest / LONG.to_f).round.clamp(1, 4)}fr" : "auto"
49
+ end
50
+ end
51
+
52
+ def text_of(cell)
53
+ cell.resources.empty? ? cell.text.to_s : cell.resources.map(&:name).join(" ")
54
+ end
55
+
56
+ def cell(cell, markdown)
57
+ return cell.resources.map { |resource| Typst.str(resource.name) }.join("#linebreak()") unless cell.resources.empty?
58
+
59
+ case cell.as
60
+ when :status then badge(cell.text, Confluence::STATUS_COLOURS)
61
+ when :priority then cell.text.to_s.empty? ? "" : badge(cell.text, Confluence::PRIORITY_COLOURS)
62
+ when :markdown then markdown.call(cell.text.to_s)
63
+ else cell.text.to_s.split("\n").map { |line| Typst.str(line) }.join("#linebreak()")
64
+ end
65
+ end
66
+
67
+ def badge(text, colours)
68
+ Typst.badge(text, Markdown::STATUS_FILL.fetch(colours.fetch(text, "grey"), "#e8e8e8"))
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typst"
4
+ require_relative "../assets"
5
+
6
+ module Archsight
7
+ module Pdf
8
+ # The look of an exported document: what a Config resource of type pdf says, with a neutral built-in look for
9
+ # everything it leaves out (or for no template at all). Every value is checked here, so a template loaded
10
+ # from a file that was never linted cannot put anything but a colour, a font name or a length into the
11
+ # Typst source.
12
+ class Theme
13
+ DEFAULT_NAME = "default"
14
+ TYPE = "pdf" # the `config/type` of the Configs that describe the look
15
+ COLORS = {
16
+ primary: "#1f3a5f", accent: "#2f7fd1", text: "#1c1c1c", muted: "#6b7280",
17
+ ok: "#1f9d55", warn: "#d98e04", danger: "#c0392b"
18
+ }.freeze
19
+ FONTS = {
20
+ body: ["Liberation Sans", "DejaVu Sans", "Helvetica", "Arial"],
21
+ heading: ["Liberation Sans", "DejaVu Sans", "Helvetica", "Arial"],
22
+ mono: ["DejaVu Sans Mono", "Liberation Mono", "Menlo", "Courier New"]
23
+ }.freeze
24
+ PAPERS = %w[a4 a5 us-letter].freeze
25
+ LANGS = %w[en de].freeze
26
+ HEADER_PARTS = %w[id version date owner].freeze
27
+ DEFAULT_MARGIN = "16mm"
28
+ LOGO_TYPES = %w[.svg .png].freeze
29
+
30
+ attr_reader :name, :colors, :fonts, :footer_text, :classification, :header_meta, :lang, :paper, :margin,
31
+ :logo, :logo_light, :font_dir
32
+
33
+ # @param template [Archsight::Resources::Config, nil]
34
+ # @param resources_dir [String] logos and fonts are looked up below it
35
+ def initialize(template = nil, resources_dir: Archsight.resources_dir)
36
+ @name = template&.name
37
+ ann = template ? template.annotations : {}
38
+ @colors = COLORS.to_h { |key, default| [key, color(ann["pdf/color-#{key}"], default)] }
39
+ @fonts = FONTS.to_h { |key, default| [key, font_list(ann["pdf/font-#{key}"], default)] }
40
+ @footer_text = blank_to_nil(ann["pdf/footer-text"])
41
+ @classification = blank_to_nil(ann["pdf/classification"])
42
+ @header_meta = header_parts(ann["pdf/header-meta"])
43
+ @lang = LANGS.include?(ann["pdf/lang"]) ? ann["pdf/lang"] : "en"
44
+ @paper = PAPERS.include?(ann["pdf/paper"]) ? ann["pdf/paper"] : "a4"
45
+ @margin = ann["pdf/margin"].to_s.match?(Resources::Config::LENGTH) ? ann["pdf/margin"] : DEFAULT_MARGIN
46
+ @logo = logo_file(ann["pdf/logo"], resources_dir)
47
+ @logo_light = logo_file(ann["pdf/logo-light"], resources_dir) || @logo
48
+ @font_dir = font_directory(ann["pdf/font-dir"], resources_dir)
49
+ end
50
+
51
+ # The Config a document asks for (`pdf:template:<name>`), else the one named `default`, else the built-in look. Only
52
+ # Configs of type pdf count.
53
+ # @param requested [String, nil] name as written in the tag, with or without the `Config:` and `PDF:` prefixes
54
+ def self.for(database, requested = nil)
55
+ wanted = (requested || DEFAULT_NAME).to_s.strip
56
+ configs = database.instances_by_kind("Config").values.select { |c| c.annotations["config/type"].to_s.casecmp?(TYPE) }
57
+ new(configs.find { |config| matches?(config.name, wanted) })
58
+ end
59
+
60
+ # `brand`, `PDF:brand` and `Config:PDF:brand` all name the same Config
61
+ def self.matches?(name, wanted)
62
+ short(name).casecmp?(short(wanted))
63
+ end
64
+
65
+ def self.short(name)
66
+ name.to_s.strip.sub(/\AConfig:/i, "").sub(/\APDF:/i, "")
67
+ end
68
+
69
+ # Typst source defining `theme`. `logo` and `logo_light` are the file names the logos were staged under.
70
+ def to_typst(logo: nil, logo_light: nil)
71
+ fields = colors.map { |key, hex| "#{key}: #{Typst.color(hex)}" }
72
+ fields += fonts.map { |key, list| "font-#{key}: (#{list.map { |f| Typst.lit(f) }.join(", ")},)" }
73
+ fields += [
74
+ "logo: #{logo ? Typst.lit(logo) : "none"}", "logo-light: #{logo_light ? Typst.lit(logo_light) : "none"}",
75
+ "footer-text: #{footer_text ? Typst.lit(footer_text) : "none"}",
76
+ "header-meta: (#{header_meta.map { |p| Typst.lit(p) }.join(", ")},)",
77
+ "lang: #{Typst.lit(lang)}", "paper: #{Typst.lit(paper)}", "margin: #{margin}"
78
+ ]
79
+ "#let theme = (#{fields.join(", ")})\n"
80
+ end
81
+
82
+ private
83
+
84
+ def color(value, default)
85
+ value.to_s.strip.match?(Resources::Config::COLOR) ? value.to_s.strip : default
86
+ end
87
+
88
+ def font_list(value, default)
89
+ name = value.to_s.strip
90
+ name.match?(Resources::Config::FONT_NAME) ? [name, *default] : default
91
+ end
92
+
93
+ def header_parts(value)
94
+ return HEADER_PARTS if value.to_s.strip.empty?
95
+
96
+ parts = value.to_s.split(",").map(&:strip) & HEADER_PARTS
97
+ parts.empty? ? HEADER_PARTS : parts
98
+ end
99
+
100
+ def blank_to_nil(value)
101
+ text = value.to_s.strip
102
+ text.empty? ? nil : text
103
+ end
104
+
105
+ # @return [String, nil] absolute path of a logo inside the resources directory
106
+ def logo_file(path, resources_dir)
107
+ return nil if path.to_s.strip.empty?
108
+
109
+ file = Assets.file_for(path.to_s, resources_dir: resources_dir)
110
+ file if file && LOGO_TYPES.include?(File.extname(file).downcase)
111
+ end
112
+
113
+ def font_directory(path, resources_dir)
114
+ normalized = Assets.normalize(path.to_s)
115
+ return nil unless normalized
116
+
117
+ root = File.realpath(resources_dir)
118
+ dir = File.realpath(File.join(root, normalized))
119
+ dir if dir.start_with?(root + File::SEPARATOR) && File.directory?(dir)
120
+ rescue SystemCallError
121
+ nil
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,152 @@
1
+ // Page shell of the Archsight PDF export. Generic: every colour, font, logo and text comes from `theme`
2
+ // (see Archsight::Pdf::Theme), every per-document value is an argument.
3
+
4
+ #let labels = (
5
+ en: (page: "Page", of: "of", contents: "Contents", draft: "DRAFT", doc-id: "Document ID", version: "Version", date: "Date", owner: "Owner", classification: "Classification"),
6
+ de: (page: "Seite", of: "von", contents: "Inhalt", draft: "ENTWURF", doc-id: "Dokument-ID", version: "Version", date: "Datum", owner: "Eigentümer", classification: "Einstufung"),
7
+ )
8
+
9
+ // Identifiers have no spaces: allow a line break after `-`, `:` and `/` and between camel-case words (no visible change)
10
+ #let breakable(s) = if type(s) != str { s } else {
11
+ s.replace(regex("([:/-])"), m => m.text + "\u{200B}").replace(regex("([a-z0-9])([A-Z])"), m => m.captures.at(0) + "\u{200B}" + m.captures.at(1))
12
+ }
13
+
14
+ // An image at its natural size, shrunk to fit the text width and `max-height` of the page, centred
15
+ #let fit-image(path, max-height: 0.5) = layout(size => context {
16
+ let natural = measure(image(path))
17
+ let limit = page.height * max-height
18
+ let w = calc.min(natural.width, size.width)
19
+ let h = w * (natural.height / natural.width)
20
+ if h > limit {
21
+ h = limit
22
+ w = h * (natural.width / natural.height)
23
+ }
24
+ align(center, image(path, width: w, height: h))
25
+ })
26
+
27
+ // The meta line of the header: the document id in the mono font, the parts separated by a light bar
28
+ #let _meta-line(theme, id, version, date, owner) = {
29
+ let t = labels.at(theme.lang)
30
+ let parts = ()
31
+ if "id" in theme.header-meta and id != none { parts.push(text(font: theme.font-mono, breakable(id))) }
32
+ if "version" in theme.header-meta and version != none { parts.push(box[#t.version #version]) }
33
+ if "date" in theme.header-meta and date != none { parts.push(box[#date]) }
34
+ if "owner" in theme.header-meta and owner != none { parts.push(box[#owner]) }
35
+ parts.join(h(4pt) + text(fill: theme.muted.lighten(60%), "|") + h(4pt))
36
+ }
37
+
38
+ // The document shell: page layout, header, footer, headings, watermark.
39
+ #let doc(
40
+ theme: none,
41
+ title: "",
42
+ doc-id: none,
43
+ version: none,
44
+ date: none,
45
+ owner: none,
46
+ classification: none,
47
+ draft: false,
48
+ landscape: false,
49
+ body,
50
+ ) = {
51
+ let t = labels.at(theme.lang)
52
+ set document(title: title)
53
+ set page(
54
+ paper: theme.paper,
55
+ flipped: landscape,
56
+ margin: (x: theme.margin, top: 22mm, bottom: 16mm),
57
+ header-ascent: 30%,
58
+ footer-descent: 35%,
59
+ header: grid(
60
+ columns: (1fr, auto),
61
+ align: (left + bottom, right + bottom),
62
+ text(size: 7pt, fill: theme.muted.lighten(30%), _meta-line(theme, doc-id, version, date, owner)),
63
+ if theme.logo != none { image(theme.logo, height: 6.5mm) },
64
+ ),
65
+ footer: {
66
+ set text(size: 7pt, fill: theme.muted)
67
+ line(length: 100%, stroke: 0.5pt + theme.muted.lighten(70%))
68
+ v(-2pt)
69
+ grid(
70
+ columns: (1fr, auto, 1fr),
71
+ column-gutter: 8pt,
72
+ align: (left + horizon, center + horizon, right + horizon),
73
+ if classification != none { text(weight: "bold", tracking: 0.06em, fill: theme.danger, classification) },
74
+ if theme.footer-text != none { text(style: "italic", theme.footer-text) },
75
+ context [#t.page #counter(page).display() #t.of #counter(page).final().first()],
76
+ )
77
+ },
78
+ background: if draft {
79
+ rotate(-45deg, text(size: 110pt, weight: "bold", fill: theme.muted.transparentize(85%), t.draft))
80
+ },
81
+ )
82
+ set text(font: theme.font-body, size: 10pt, fill: theme.text, lang: theme.lang)
83
+ set par(justify: false, leading: 0.62em)
84
+ show raw: set text(font: theme.font-mono, size: 8.5pt)
85
+ show raw.where(block: true): it => block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%, it)
86
+ show heading: set text(font: theme.font-heading, fill: theme.primary)
87
+ show heading.where(level: 1): set text(size: 16pt)
88
+ show heading.where(level: 2): set text(size: 13pt)
89
+ show heading.where(level: 3): set text(size: 11pt)
90
+ show link: set text(fill: theme.accent)
91
+ // Tables have horizontal rules only: a stronger one under the header, a light one under every other row, no frame and
92
+ // no vertical lines; the first column is flush with the text. Tables that pass their own stroke keep it.
93
+ set table(
94
+ stroke: (x, y) => if y == 0 { (bottom: 0.7pt + theme.muted.lighten(35%)) } else { (bottom: 0.4pt + theme.muted.lighten(75%)) },
95
+ inset: (x, y) => (left: if x == 0 { 0pt } else { 6pt }, right: 6pt, y: 6pt),
96
+ )
97
+ set table.cell(breakable: false)
98
+ show table.header: set text(weight: "semibold")
99
+ body
100
+ }
101
+
102
+ // Title with the kind at its right, a hairline directly below, then an optional intro
103
+ #let title-block(theme: none, title: "", kind: none, intro: none) = {
104
+ grid(
105
+ columns: (1fr, auto),
106
+ align: (left + bottom, right + bottom),
107
+ text(font: theme.font-heading, size: 22pt, weight: "bold", fill: theme.primary, breakable(title)),
108
+ if kind != none { text(size: 8pt, fill: theme.muted, kind) },
109
+ )
110
+ v(-4pt)
111
+ line(length: 100%, stroke: 0.5pt + theme.muted.lighten(70%))
112
+ if intro != none { block(above: 8pt, below: 6pt, text(size: 9pt, fill: theme.muted, intro)) }
113
+ }
114
+
115
+ // A cover page without header and footer: a band with the logo at its bottom left, kind, title and a short rule, the
116
+ // document meta at the bottom left of the page. The pages after it are numbered from 1.
117
+ #let cover-page(theme: none, title: "", kind: none, doc-id: none, version: none, date: none, owner: none, classification: none) = {
118
+ let t = labels.at(theme.lang)
119
+ page(header: none, footer: none, margin: 0mm, background: none)[
120
+ #block(width: 100%, height: 52mm, fill: theme.primary, inset: (x: theme.margin, bottom: 12mm), align(left + bottom, if theme.logo-light != none { image(theme.logo-light, height: 9mm) }))
121
+ #block(width: 100%, inset: (x: theme.margin, top: 34mm), {
122
+ if kind != none {
123
+ text(size: 7.5pt, weight: 600, tracking: 0.04em, fill: theme.accent, upper(kind))
124
+ v(2pt)
125
+ }
126
+ par(leading: 0.4em, text(font: theme.font-heading, size: 28pt, weight: "bold", fill: theme.primary, breakable(title)))
127
+ v(10pt)
128
+ line(length: 36mm, stroke: 2pt + theme.accent)
129
+ })
130
+ #let meta = (
131
+ (t.doc-id, if doc-id != none { text(font: theme.font-mono, breakable(doc-id)) }),
132
+ (t.version, version),
133
+ (t.date, date),
134
+ (t.owner, owner),
135
+ (t.classification, if classification != none { text(weight: "bold", fill: theme.danger, classification) }),
136
+ ).filter(m => m.at(1) != none)
137
+ #place(bottom + left, dx: theme.margin, dy: -20mm, grid(
138
+ columns: 2,
139
+ column-gutter: 14pt,
140
+ row-gutter: 7pt,
141
+ ..meta.map(((k, v)) => (text(size: 8pt, fill: theme.muted, k), text(size: 9pt, v))).flatten(),
142
+ ))
143
+ // the pages after the cover count from 1 (set inside the cover, so that nothing after it forces another page)
144
+ #counter(page).update(0)
145
+ ]
146
+ }
147
+
148
+ #let toc-page(theme: none, depth: 3) = {
149
+ let t = labels.at(theme.lang)
150
+ outline(title: t.contents, depth: depth)
151
+ pagebreak()
152
+ }
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Pdf
5
+ # Building blocks for Typst source (https://typst.app). All text is emitted as Typst string literals
6
+ # (`#"..."`) and structure as function calls (`#strong[...]`), so no content can be mistaken for markup.
7
+ module Typst
8
+ LANGUAGE = /\A[A-Za-z0-9+#-]+\z/
9
+
10
+ module_function
11
+
12
+ # A Typst string literal, for use as a function argument
13
+ def lit(text)
14
+ escaped = text.to_s.delete("\r").gsub(/[\\"]/) { "\\#{Regexp.last_match(0)}" }.gsub("\n", "\\n")
15
+ "\"#{escaped}\""
16
+ end
17
+
18
+ # A Typst string literal as markup, for use inside content
19
+ def str(text)
20
+ "##{lit(text)}"
21
+ end
22
+
23
+ # A block of preformatted text
24
+ def raw(source, lang = nil)
25
+ lang_arg = lang.to_s.match?(LANGUAGE) ? ", lang: #{lit(lang)}" : ""
26
+ "#raw(#{lit(source.to_s.chomp)}, block: true#{lang_arg})"
27
+ end
28
+
29
+ # A short text on a coloured background (a status)
30
+ def badge(text, hex)
31
+ "#box(fill: #{color(hex)}, inset: (x: 4pt, y: 1pt), radius: 2pt)[#{str(text)}]"
32
+ end
33
+
34
+ # A framed message that stands where something could not be shown
35
+ def note(text, fill: "#fff3cc")
36
+ "#block(fill: #{color(fill)}, inset: 8pt, radius: 3pt, width: 100%)[#{str(text)}]"
37
+ end
38
+
39
+ # A Typst colour from `#rrggbb` / `#rgb`
40
+ def color(hex)
41
+ "rgb(#{lit(hex)})"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require_relative "pdf/compiler"
6
+ require_relative "pdf/document"
7
+
8
+ module Archsight
9
+ # PDF export of pages and resources, laid out by Typst (https://typst.app, installed as the `typst` command).
10
+ # See Archsight::Pdf::Document for what steers it and Archsight::Resources::Config (type pdf) for the look.
11
+ module Pdf
12
+ module_function
13
+
14
+ # @param resource [Archsight::Resources::Base] a page or any other resource
15
+ # @return [Archsight::Pdf::Document]
16
+ def document(database, resource, compiler: Compiler.new, **)
17
+ Document.new(database, resource, pdf_images: compiler.pdf_images?, **)
18
+ end
19
+
20
+ # The PDF of a prepared document
21
+ # @return [String]
22
+ # @raise [Unavailable, Error]
23
+ def compile(document, compiler: Compiler.new)
24
+ Dir.mktmpdir("archsight-pdf") do |dir|
25
+ document.files.each { |name, content| File.binwrite(File.join(dir, name), content) }
26
+ compiler.compile(dir, font_paths: document.font_paths)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -26,9 +26,10 @@ module Archsight
26
26
  # Adds the derived relations to every resource of the database.
27
27
  def derive!(database)
28
28
  finder = Finder.new(database)
29
+ includes = Helpers::DiagramIncludes.for(database)
29
30
  database.instances.values.flat_map(&:values).each do |inst|
30
31
  text_sources(inst).each { |markdown| mentions(markdown, finder).each { |target| link(inst, :mentions, target) } }
31
- diagram_sources(inst, database).each { |source| depictions(source, finder).each { |target| link(inst, :depicts, target) } }
32
+ diagram_sources(inst, database).each { |source| depictions(source, finder, includes).each { |target| link(inst, :depicts, target) } }
32
33
  end
33
34
  end
34
35
 
@@ -44,8 +45,8 @@ module Archsight
44
45
  # The resources the nodes of a diagram source name with `resource "..."`; a source that is not a valid diagram
45
46
  # names none.
46
47
  # @return [Array<Base>]
47
- def depictions(source, finder)
48
- Diagram.resource_references(source).filter_map { |reference| finder.reference(reference) }
48
+ def depictions(source, finder, includes = nil)
49
+ Diagram.resource_references(source, includes: includes).filter_map { |reference| finder.reference(reference) }
49
50
  rescue Diagram::Error
50
51
  []
51
52
  end
@@ -55,6 +55,13 @@ class Archsight::Resources::ApplicationComponent < Archsight::Resources::Base
55
55
  works in queries like a tag but is not a sidebar filter, as there are too many distinct values. Deployable forms (container image, chart, package) are `TechnologyArtifact`s, not
56
56
  component types.
57
57
 
58
+ ## Teams
59
+
60
+ A component is `maintainedBy` the team (`BusinessActor`) that builds and changes it and `operatedBy` the team
61
+ (`BusinessActor` or `BusinessRole`) that runs it in production. Build and run are often different teams, which is
62
+ the point of writing both. Both are optional; without a direct `maintainedBy`, the build team is the one that
63
+ maintains the component's `TechnologyArtifact`s.
64
+
58
65
  ## Where it runs
59
66
 
60
67
  A component is `servedBy` the `TechnologyService` it is deployed on (a product's cluster, a managed platform) and
@@ -426,6 +433,9 @@ class Archsight::Resources::ApplicationComponent < Archsight::Resources::Base
426
433
  relation :servedBy, :technologyComponents, :TechnologySystemSoftware
427
434
  relation :servedBy, :technologyServices, :TechnologyService
428
435
  relation :provisions, :technologyServices, :TechnologyService
436
+ relation :maintainedBy, :businessActors, :BusinessActor
437
+ relation :operatedBy, :businessActors, :BusinessActor
438
+ relation :operatedBy, :businessRoles, :BusinessRole
429
439
  relation :exposes, :applicationInterfaces, :ApplicationInterface
430
440
  relation :dependsOn, :applicationInterfaces, :ApplicationInterface
431
441
  relation :dependsOn, :applicationComponents, :ApplicationComponent