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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c249926b6ff6855b19ace38f11b9f256340e4d77cd00fe4d5f981fcbb0fd12f1
4
- data.tar.gz: a0d7267da0223f8faed9a478a2c5ca029cdc23205fdff4feb945adb4d2b78f2e
3
+ metadata.gz: f7ada570ca5a5647d0cb9dc9f23b37d8adc9c0b6eff5420154c8ef3d30d9ea95
4
+ data.tar.gz: ba2e28e035b6d091fbfaeef71b0bc8d775f84c88541ecea0de86ba7d256bde42
5
5
  SHA512:
6
- metadata.gz: 18ae964be21eded1e7be2560cd70a0ea63cb714c76b2d5295b3ec4b1f2b1fc46f5ec418591f53c5856babe389696b96be1436e905ee5ee2bc4b922ecee43d816
7
- data.tar.gz: c8c8fbcba5d1f9db3b9d22c9fb2b1182569354fd08e2fd2cf44ba0bfe4b787ffac0446d48db5bd802f01269ea9ff6746383b2887bdf0a0f4b8d9e97e2ee2f9f6
6
+ metadata.gz: 739c46f505dfa73125839be21c2f0baaba7b4ba2a98926ca449a2cf87088cbc2c910c6b016c67ac7905540430927f94f2dc1b0bdce29735aec1ab29857390d7c
7
+ data.tar.gz: c259164f01f0d14e47dc5bf7f25a572a1a519dcc92ca788f3191274df5b6e0fd4d3d90fcdc5bc253bca6b454db464aa6ab46eaffa1f5a7f515b000a307506324
data/Dockerfile CHANGED
@@ -25,6 +25,10 @@ ENV GEM_HOME=/usr/local/bundle
25
25
  ENV GEM_PATH=/usr/local/bundle:/usr/local/lib/ruby/gems/4.0.0
26
26
  ENV PATH="/usr/local/bundle/bin:${PATH}"
27
27
 
28
+ # PDF export (Archsight::Pdf): typst typesets the documents, rsvg-convert turns the diagrams into vector graphics,
29
+ # graphviz draws the dependency graph of a resource
30
+ RUN apk add --no-cache typst rsvg-convert font-liberation font-dejavu font-noto-emoji graphviz
31
+
28
32
  RUN mkdir -p /resources
29
33
 
30
34
  ENV ARCHSIGHT_RESOURCES_DIR=/resources
@@ -37,6 +37,11 @@ that is set but empty counts as not set.
37
37
  | `jira.server` | `ARCHSIGHT_JIRA_SERVER` | Name of the Jira server as Confluence knows it (the `server` parameter of its Jira macro). Needed by the Confluence export when Confluence has more than one Jira server: without it the exported `{jira:KEY}` macros have no server and Confluence shows an error |
38
38
  | `jira.server_id` | `ARCHSIGHT_JIRA_SERVER_ID` | Id of that server (the `serverId` parameter). Copy both from the storage format of a Jira macro that already works in Confluence (page "Edit in storage format") |
39
39
  | `jira.token` | `ARCHSIGHT_JIRA_TOKEN` | Reserved, nothing uses it yet |
40
+ | `pdf.typst` | `ARCHSIGHT_PDF_TYPST` | Path of the `typst` command that typesets the [PDF export](/doc/pdf-export), if it is not on the `PATH` |
41
+ | `pdf.dot` | `ARCHSIGHT_PDF_DOT` | Path of Graphviz' `dot` command that draws the dependency graph in the [PDF export](/doc/pdf-export), if it is not on the `PATH`. Without it the PDF of a resource has no graph |
42
+ | `pdf.timeout` | `ARCHSIGHT_PDF_TIMEOUT` | Seconds a PDF may take to typeset before it is cancelled. Default `60` |
43
+ | `pdf.remote_images` | `ARCHSIGHT_PDF_REMOTE_IMAGES` | `false` stops the PDF export from fetching images that a page embeds by URL (default: fetched, public hosts only) |
44
+ | `pdf.max_analyses` | `ARCHSIGHT_PDF_MAX_ANALYSES` | How many `![[Analysis/...]]` embeds one PDF may run (each can take as long as its own timeout). Default `5`; further ones are shown as a note |
40
45
  | `import.cache_root` | `ARCHSIGHT_IMPORT_CACHE_ROOT` | Where the GitHub, GitLab and repository imports clone repositories, and what importers of the resources repository get as `cache_root` / `ARCHSIGHT_CACHE_DIR`. Default `~/.cache/archsight/git`; an import's own `import/config/cacheRoot` wins, see [Importers of the Resources Repository](/doc/import#importers-of-the-resources-repository). Every import makes sure the clones are not indexed by Spotlight (macOS): an empty `.metadata_never_index` in `~/.cache/archsight`, or in the cache root when it is elsewhere |
41
46
 
42
47
  ## Command line
data/docs/diagram.md CHANGED
@@ -43,6 +43,7 @@ archsight diagram three_tier.asd --relation=all # also show control/data-flow
43
43
  archsight diagram overview.asd --theme=compact # denser spacing + smaller fonts (or cozy, tight)
44
44
  archsight diagram overview.asd --legend=bottom # legend below (or right/left/top/none; default auto)
45
45
  archsight diagram *.asd # render every file to its own .svg
46
+ archsight diagram app.asd --resources=model/ # resolve include \"file://...\" paths from this directory
46
47
  archsight diagram --format *.asd # re-indent the sources in place
47
48
  archsight diagram --format --check *.asd # list unformatted sources, exit 1 if any (CI)
48
49
  ```
@@ -144,6 +145,11 @@ hint for near misses), never silently ignored.
144
145
  | `link` | nodes, containers | `http(s)://…`, `mailto:…` or a relative URL |
145
146
  | `resource` | nodes, containers | a resource name (`Archsight:Web`) or `Kind/Name` (`ApplicationService/Archsight:Web`); see *Linking to resources* |
146
147
  | `extend` | nodes, containers | `true`, `false`; containers also `height` |
148
+ | `anchor` | containers | `top`, `bottom`, `left`, `right`, `center` |
149
+
150
+ `extend "height"` on a direct child of a `layer` grows it to its tallest sibling. Inside such a box (also through a `boundary` around a `stack`), a `stack` child carrying `extend "height"` takes the extra height and moves the ranks below it down, so e.g. a footer rank lines up across the columns. The child can be a `layer` too; its own `extend "height"` children then fill the layer's new height.
151
+
152
+ `anchor` says where a container's content sits when its box grows: `bottom` (or `top`) when it is made taller, `right` (or `left`) when it is made wider, `center` for the middle. Default: top for `extend "height"`, centered otherwise. It is not inherited, and has no effect on a box whose room is taken by a stack child with `extend "height"`.
147
153
  | `shape` | leaves | `rectangle`, `circle`, `cylinder`, `pipe`, `actor`, `file`, `module` |
148
154
  | `gap` | containers | pixels (`40`), a percentage (`150%`) or a signed one (`+20%`, `-50%`) |
149
155
  | `ranks` | containers | `auto`, `on`, `down`, `right`, `off` |
@@ -298,6 +304,69 @@ no broken styling. Library users pass a `resolver:` to `Diagram.render`
298
304
  (any `call(reference)` returning a URL, `nil` for a resource that exists but has nowhere to link to (the node stays as it is), `:missing` or `:ambiguous`, see
299
305
  `Archsight::Helpers::ResourceResolver`) and, to collect failures, `unresolved: []`.
300
306
 
307
+ ## Including diagrams
308
+
309
+ `include "<path>"` splices another diagram in at that position, so a part
310
+ that several diagrams share (a platform, a network, a team's services) is
311
+ written once:
312
+
313
+ ```
314
+ group "app" {
315
+ component "web" { label "Web server" }
316
+ stack {
317
+ include "archsight://ApplicationComponent/Platform:Database"
318
+ }
319
+ }
320
+ include "file://shared/network.asd"
321
+ web -> db
322
+ ```
323
+
324
+ The options of an `include` go in braces after the path (`namespace`, see below).
325
+
326
+ | Path | Included diagram |
327
+ |------|------------------|
328
+ | `archsight://<Kind>/<name>` | the `architecture/diagram` annotation of that resource |
329
+ | `file://<path>` | an `.asd` file, relative to the resources directory (also in nested includes) |
330
+
331
+ A path that leaves the resources directory (`..`, absolute paths, symlinks
332
+ pointing out) or is not an `.asd` file is refused. An `include` stands
333
+ wherever a `layer` or `stack` can: at the top level or inside a container
334
+ (not inside a leaf like `component`, and not together with `columns`). Included
335
+ diagrams may include others (up to 8 levels, 100 includes in all; a cycle is an
336
+ error). Nodes are one namespace across all of it, so an id defined twice is an
337
+ error, and an edge in either part may name a node of the other.
338
+
339
+ ### Including a diagram more than once
340
+
341
+ Ids are one namespace, so including the same diagram twice would define
342
+ every node twice. A `namespace` puts `<namespace>.` in front of every id the
343
+ included diagram defines (nodes and dataflows, and the references to them inside
344
+ it):
345
+
346
+ ```
347
+ include "archsight://ApplicationComponent/Platform:Server" { namespace "a" }
348
+ include "archsight://ApplicationComponent/Platform:Server" { namespace "b" }
349
+ a.web -> b.db
350
+ ```
351
+
352
+ A node without a `label` keeps showing its own id (`web`, not `a.web`), and the same
353
+ id from different includes no longer clashes. A reference inside the included diagram to an id it
354
+ does not define names a node of the including diagram and stays as it is. Namespaces
355
+ nest: a diagram included with `namespace "o"` that itself includes one with `namespace "in"` has
356
+ `o.in.…` ids. A namespace is a name of letters, digits, `_` and `-`, joined by dots.
357
+
358
+ Dataflows and `theme`/`legend`/`ranks` apply to the whole diagram: an included
359
+ diagram holding them keeps them when it is included at the top level, and they are
360
+ ignored, with a warning, when it is included inside a container. The warning is
361
+ written to the server log and listed by `archsight lint`.
362
+
363
+ A missing resource or file, a bad path or a broken included diagram fails the
364
+ diagram with an error that names the include (`archsight lint` reports it). Standalone
365
+ `archsight diagram` takes `file://` paths relative to `--resources DIR` (default:
366
+ the directory of the input) and has no `archsight://`. Library users pass `includes:` to
367
+ `Diagram.render`, any `call(path)` returning the source or `:missing`, `:forbidden` or `:invalid`
368
+ (see `Archsight::Helpers::DiagramIncludes`), and `warnings: []` to collect the warnings.
369
+
301
370
  ## Diagrams in markdown
302
371
 
303
372
  A fenced code block tagged `asd` in any markdown the web UI renders (docs,
data/docs/index.md.erb CHANGED
@@ -8,6 +8,7 @@ Welcome to the Architecture Documentation System. This documentation covers how
8
8
  - [Configuration](/doc/configuration) - The configuration file and environment variables for tokens and URLs
9
9
  - [Query Syntax](/doc/search) - Search and filter resources
10
10
  - [Wiki Pages](/doc/pages) - Author markdown pages and arrange them in the sidebar tree
11
+ - [PDF Export](/doc/pdf-export) - Download pages and resources as PDF, with a Config for the look
11
12
  - [Architecture Modeling](/doc/modeling) - How to model your architecture
12
13
  - [REST API Documentation](/docs/api) - JSON API for programmatic access
13
14
  - [Diagrams](/doc/diagram) - Render `.asd` diagram files to SVG
data/docs/modeling.md CHANGED
@@ -36,7 +36,7 @@ There are two families of verbs:
36
36
 
37
37
  | Family | Verbs | Written on | Points to |
38
38
  |--------|-------|------------|-----------|
39
- | Responsibility and provider (down) | `performedBy`, `ownedBy`, `executedBy`, `servedBy`, `guidedBy`, `maintainedBy`, `contributedBy`, `hasConcern`, `representedBy`, `realizedThrough`, `exposes`, `provisions`, `connects`, `mitigatedBy`, `influences`, `triggers`, `affects`, `assesses`, `contains`, `closedBy`, `compares` | the element that needs, uses or is concerned with something | what provides it or is responsible for it |
39
+ | Responsibility and provider (down) | `performedBy`, `ownedBy`, `operatedBy`, `executedBy`, `servedBy`, `guidedBy`, `maintainedBy`, `contributedBy`, `hasConcern`, `representedBy`, `realizedThrough`, `exposes`, `provisions`, `connects`, `mitigatedBy`, `influences`, `triggers`, `affects`, `assesses`, `contains`, `closedBy`, `compares` | the element that needs, uses or is concerned with something | what provides it or is responsible for it |
40
40
  | Realization (up) | `realizes`, `partiallyRealizes`, `plans`, `satisfies`, `evidencedBy` (on the evidenced element), `provides` | the concrete element | the abstract element it answers to (requirement, goal, capability, plateau) |
41
41
 
42
42
  Rules:
@@ -616,6 +616,8 @@ Relations are written on the element that is placed (see [Direction of Relations
616
616
  | `TechnologyFacility` | `composedOf` | `TechnologyFacility` (nested sites, the rooms of a data center) |
617
617
  | `TechnologyFacility` | `servedBy` | `BusinessActor` (the operator) |
618
618
  | `TechnologyDevice`, `TechnologyEquipment`, `TechnologyNode`, `TechnologyCommunicationNetwork` | `ownedBy` | `BusinessActor`, `BusinessRole` (the team that operates it) |
619
+ | `ApplicationComponent`, `ApplicationService`, `TechnologyService` | `operatedBy` | `BusinessActor`, `BusinessRole` (the team that runs it in production) |
620
+ | `ApplicationComponent`, `ApplicationService`, `TechnologyService` | `maintainedBy` | `BusinessActor` (the team that builds and changes it) |
619
621
  | `TechnologyDevice`, `TechnologyEquipment`, `TechnologyNode` | `servedBy` | `BusinessActor` (the team it is dedicated to, e.g. a test partition) |
620
622
 
621
623
  `locatedAt` with many targets is the point: a service that runs in three data centers is **one** service with three
@@ -0,0 +1,118 @@
1
+ # PDF Export
2
+
3
+ Every [page](/doc/pages) and every resource can be downloaded as a PDF. The document is typeset by [Typst](https://typst.app),
4
+ so the server (or the machine running the CLI) needs the `typst` command; the container image has it, together with
5
+ `rsvg-convert` (turns diagrams into vector graphics), `dot` of Graphviz (draws the dependency graph) and a few fonts.
6
+
7
+ ## Getting a PDF
8
+
9
+ - **Web UI:** the **PDF** button next to **Edit** on a page or resource.
10
+ - **API:** `GET /api/v1/pages/{name}/pdf` and `GET /api/v1/kinds/{kind}/instances/{name}/pdf`, see the [REST API documentation](/docs/api).
11
+ The response has an `ETag` that changes whenever the page, an image, a diagram or the template changes.
12
+ Query parameters: `template` (name of a pdf Config) and `draft=1` (watermark).
13
+ - **CLI:** `archsight pdf <page>` or `archsight pdf Kind/Name`, with `-o file.pdf`, `--template` and `--draft`.
14
+
15
+ Without `typst` the API answers `503` and the button shows the reason.
16
+
17
+ ## What is in the document
18
+
19
+ - A **page** is typeset as written: headings, text, tables (with their alignment), task lists, code, images next to the page,
20
+ `asd` diagrams (as vector graphics), `[[links]]` (as their label), `{status:...}` (a coloured box), `{jira:KEY}` (a link
21
+ when `jira.issue_url` is set) and `{emoticon:...}`. Macros that need the running application (`{children}`) are left out.
22
+ Mermaid blocks and draw.io images cannot be drawn on the server: a note stands there (and the Mermaid source).
23
+ - **Tables** have horizontal rules only: a stronger one under the header row and a light one under every other row, no frame
24
+ and no vertical lines, in the muted colour of the Config; the first column is flush with the text.
25
+ - **Live content is the data of the moment of the export**, as in the Confluence export:
26
+ - `![[View/Name]]` and ```` ```view ```` blocks are tables (title, number of items, repeated header row; long columns
27
+ share the width), `` ```requirements `` blocks are the requirements table.
28
+ - `![[Analysis/Name]]` runs the analysis and puts its sections (headings, text, tables, lists, messages) into the document.
29
+ An analysis runs for as long as its own `analysis/timeout` allows, so at most 5 run per document (`pdf.max_analyses`),
30
+ and a result is reused for a minute. A failed analysis shows its error.
31
+ - `![[Kind/Name]]` of another kind is the resource's description.
32
+ - Only an embed on a line of its own (or several, one per line) is content; inside a sentence it stays its name.
33
+ - What cannot be shown (unknown or ambiguous reference, an invalid view or requirements block) is a visible note in the
34
+ document, never blank space. Embeds inside an embedded description are not expanded again.
35
+ - **Images by URL** (`![](https://...)`) are fetched by the server so they can go into the PDF. Only public hosts on https/http
36
+ are contacted (never loopback, private or link-local addresses, so the server cannot be used to reach the network
37
+ behind it), at most 10 MB and 10 seconds each, and only real png, jpeg, gif or webp files are used; otherwise the alt text
38
+ is shown. `pdf.remote_images: false` turns this off.
39
+ - Any other **resource** becomes a generated document: its description, its properties, its relations and its
40
+ `architecture/diagram`, and its **dependency graph**, shown first, as on its page (the resources its relations lead to, two steps
41
+ deep, following its own layer and the one below it, nodes coloured by layer), drawn by `dot` in the direction, left to
42
+ right or top to bottom, in which it is larger on the page. A resource without
43
+ relations has no graph, and without `dot` the document is made without it. Properties are a label and value list (related annotations kept together; lists as "a, b";
44
+ multi-line and markdown values as sections of their own). Relations are shown in both directions, **Outgoing** (what this
45
+ resource points to) and **Incoming** (what points to it, read from the other side): the relation is named once, with the
46
+ kind next to it and the resources flowing in two columns, so a resource with many relations stays readable.
47
+
48
+ ## Steering the layout with tags
49
+
50
+ Tags of the page (`tags:` in the frontmatter) or of a resource (`architecture/tags`):
51
+
52
+ | Tag | Effect |
53
+ |-----|--------|
54
+ | `pdf:cover` | A cover page (logo band, title, id, version, date, owner) instead of the title block |
55
+ | `pdf:toc` | A table of contents |
56
+ | `pdf:landscape` | Landscape pages |
57
+ | `pdf:draft` | A "draft" watermark on every page |
58
+ | `pdf:no-graph` | Leave the dependency graph out of a resource's document |
59
+ | `pdf:template:<name>` | Use the pdf Config `<name>` instead of `default` |
60
+ | `version:<x>` | The document version in the header (else the page property `Version`, else `1`) |
61
+
62
+ Further page data used by the layout: `title`, `owner` (else `author`), `updated` (else today), and the page properties
63
+ `Document ID` (default `Kind/name`) and `Classification` (red marking in the footer, default from the template).
64
+
65
+ ## The look: Config
66
+
67
+ A `Config` resource (layer *Other*) holds settings of a feature of the tool, kept with the model; `config/type` says
68
+ which feature. The PDF export reads the Configs of type `pdf`: colours, fonts, logo and the text around the pages. The one
69
+ named `default` is used for every document that does not name another (`pdf:template:<name>`); `brand`, `PDF:brand` and
70
+ `Config:PDF:brand` all name the same Config. Without any a neutral look is used. A Config of another type is never used
71
+ for the PDF, so every feature can have its own `default`. (The per-user file with tokens and URLs stays
72
+ [`~/.config/archsight/archsight.yaml`](/doc/configuration).)
73
+
74
+ ```yaml
75
+ apiVersion: architecture/v1alpha1
76
+ kind: Config
77
+ metadata:
78
+ name: Config:PDF:default
79
+ annotations:
80
+ config/type: pdf
81
+ pdf/color-primary: "#1f3a5f"
82
+ pdf/color-accent: "#2f7fd1"
83
+ pdf/logo: assets/logo.svg
84
+ pdf/logo-light: assets/logo-white.svg
85
+ pdf/footer-text: Generated with Archsight
86
+ pdf/classification: Internal
87
+ pdf/font-body: Open Sans
88
+ pdf/font-dir: assets/fonts
89
+ spec: {}
90
+ ```
91
+
92
+ | Annotation | Meaning |
93
+ |------------|---------|
94
+ | `config/type` | `pdf` for these settings |
95
+ | `pdf/color-primary`, `-accent`, `-text`, `-muted`, `-ok`, `-warn`, `-danger` | Hex colours (`#rrggbb`) |
96
+ | `pdf/font-body`, `-heading`, `-mono` | Font families, tried before the built-in fallbacks |
97
+ | `pdf/font-dir` | Directory (relative to the resources directory) with font files (ttf, otf) that ship with the model |
98
+ | `pdf/logo`, `pdf/logo-light` | svg or png, relative to the resources directory. `logo` is shown on white (page header), `logo-light` on the cover band |
99
+ | `pdf/footer-text`, `pdf/classification` | Texts in the footer of every page |
100
+ | `pdf/header-meta` | Parts of the header line: `id, version, date, owner` |
101
+ | `pdf/lang` | Language of the fixed texts: `en` or `de` |
102
+ | `pdf/paper`, `pdf/margin` | `a4`, `a5` or `us-letter`; horizontal margin such as `16mm` |
103
+
104
+ Values are checked when they are loaded (and by `archsight lint`): something that is not a colour, a font name or a length is
105
+ ignored and the default is used.
106
+
107
+ ## Seeing the look
108
+
109
+ The page of a Config of type `pdf` shows its settings as they look (the colours, the fonts, the logo on white and on
110
+ the cover band, the page settings) and, beside them, a **preview**: a fixed sample document (a cover page and a page with
111
+ headings, a list, a quote, a table with status badges and code) typeset by the server with exactly these settings, one
112
+ tab for the cover and one for the page. It shows the saved Config; after an edit the page typesets it again.
113
+ The same sample is `GET /api/v1/kinds/Config/instances/{name}/preview` (`?part=cover` or `?part=page` for one page).
114
+
115
+ ## Settings
116
+
117
+ `pdf.typst` (`ARCHSIGHT_PDF_TYPST`) is the path of the `typst` command if it is not on the `PATH`, `pdf.dot` (`ARCHSIGHT_PDF_DOT`) the same for `dot`, `pdf.timeout`
118
+ (`ARCHSIGHT_PDF_TIMEOUT`) the seconds a document may take (default 60), `pdf.remote_images` (`ARCHSIGHT_PDF_REMOTE_IMAGES`) `false` to never fetch images by URL, `pdf.max_analyses` (`ARCHSIGHT_PDF_MAX_ANALYSES`) the number of analyses one document may run (default 5). See [Configuration](/doc/configuration).
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../markdown"
4
+ require_relative "../pdf/typst"
5
+ require_relative "../pdf/markdown"
4
6
  require_relative "../diagram"
5
7
 
6
8
  module Archsight
@@ -10,20 +12,18 @@ module Archsight
10
12
  # All text is emitted as Typst string literals (`#"..."`) and structure as
11
13
  # function calls (`#strong[...]`), so no content can be mistaken for markup.
12
14
  class TypstRenderer
15
+ include Archsight::Pdf::Typst
16
+
13
17
  ROW_LIMIT = 10
14
18
  HEADING_OFFSET = 2 # section level 0 becomes a level-2 heading, the analysis name is level 1
15
- LANGUAGE = /\A[A-Za-z0-9+#-]+\z/
16
19
  MESSAGE_FILL = { error: "rgb(\"#fde8e8\")", warning: "rgb(\"#fff3cc\")", info: "rgb(\"#ddeeff\")" }.freeze
17
- SYMBOLS = {
18
- ndash: "–", mdash: "—", hellip: "…", laquo: "«", raquo: "»", laquo_space: "« ", raquo_space: " »",
19
- lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”"
20
- }.freeze
21
20
 
22
21
  PREAMBLE = <<~TYPST
23
22
  #set document(title: "Archsight analysis")
24
23
  #set page(paper: "a4", margin: 2cm, numbering: "1")
25
24
  #set text(size: 10pt)
26
25
  #show raw.where(block: true): it => block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%, it)
26
+ #set table(stroke: (x, y) => if y == 0 { (bottom: 0.7pt + luma(150)) } else { (bottom: 0.4pt + luma(220)) }, inset: (x, y) => (left: if x == 0 { 0pt } else { 6pt }, right: 6pt, y: 6pt))
27
27
  TYPST
28
28
 
29
29
  # @param results [Array<Archsight::Analysis::Result>]
@@ -31,10 +31,12 @@ module Archsight
31
31
  # @param template [String, nil] Typst file to import instead of the built-in layout. It must define
32
32
  # `archsight-report` (a show rule for the document) and `analysis-result` (one call per analysis,
33
33
  # with the data sections as a dictionary and the other sections as body).
34
- def initialize(results, verbose: false, template: nil)
34
+ # @param full [Boolean] show every table row and list item, without the error backtrace of `verbose`
35
+ def initialize(results, verbose: false, template: nil, full: false)
35
36
  @results = results
36
37
  @verbose = verbose
37
38
  @template = template
39
+ @full = full
38
40
  end
39
41
 
40
42
  # @return [String] Typst source
@@ -44,6 +46,17 @@ module Archsight
44
46
  [PREAMBLE, title, summary, *@results.map { |result| render_result(result) }].join("\n")
45
47
  end
46
48
 
49
+ # One section of a result as Typst markup (nil for data that only templates use)
50
+ # @param heading_offset [Integer] added to the level of a heading section
51
+ def section(section, heading_offset: HEADING_OFFSET)
52
+ render_section(section, heading_offset)
53
+ end
54
+
55
+ # The error of a failed result as Typst markup
56
+ def error(result)
57
+ error_block(result)
58
+ end
59
+
47
60
  private
48
61
 
49
62
  def title
@@ -120,9 +133,9 @@ module Archsight
120
133
  lines.join("\n\n")
121
134
  end
122
135
 
123
- def render_section(section)
136
+ def render_section(section, heading_offset = HEADING_OFFSET)
124
137
  case section[:type]
125
- when :heading then "#heading(level: #{[section[:level] + HEADING_OFFSET, 6].min})[#{str(section[:text])}]"
138
+ when :heading then "#heading(level: #{[section[:level] + heading_offset, 6].min})[#{str(section[:text])}]"
126
139
  when :text then markdown(section[:content])
127
140
  when :table then table(section[:headers], section[:rows])
128
141
  when :list then list(section[:items])
@@ -139,15 +152,14 @@ module Archsight
139
152
 
140
153
  # A template produces a document to hand out, so it gets every row
141
154
  def full?
142
- @verbose || !@template.nil?
155
+ @verbose || @full || !@template.nil?
143
156
  end
144
157
 
145
158
  def table(headers, rows)
146
159
  shown = full? ? rows : rows.first(ROW_LIMIT)
147
- head = headers.map { |header| "[#strong[#{str(header)}]]" }
160
+ head = headers.map { |header| "[#text(weight: \"semibold\")[#{str(header)}]]" }
148
161
  cells = shown.flat_map { |row| headers.each_index.map { |i| "[#{str(row[i])}]" } }
149
- out = "#table(columns: #{headers.size}, inset: 5pt, stroke: 0.5pt + luma(200), " \
150
- "table.header(#{head.join(", ")}), #{cells.join(", ")})"
162
+ out = "#table(columns: #{headers.size}, table.header(#{head.join(", ")}), #{cells.join(", ")})"
151
163
  out += "\n\n#emph[#{str("...and #{rows.size - ROW_LIMIT} more rows")}]" if shown.size < rows.size
152
164
  out
153
165
  end
@@ -172,73 +184,8 @@ module Archsight
172
184
  [message_block(:error, "Diagram could not be rendered: #{e.message}"), raw(source, "asd")].join("\n\n")
173
185
  end
174
186
 
175
- def raw(source, lang)
176
- lang_arg = lang.to_s.match?(LANGUAGE) ? ", lang: #{lit(lang)}" : ""
177
- "#raw(#{lit(source.to_s.chomp)}, block: true#{lang_arg})"
178
- end
179
-
180
- # A Typst string literal, for use as a function argument
181
- def lit(text)
182
- escaped = text.to_s.delete("\r").gsub(/[\\"]/) { "\\#{Regexp.last_match(0)}" }.gsub("\n", "\\n")
183
- "\"#{escaped}\""
184
- end
185
-
186
- # A Typst string literal as markup, for use inside content
187
- def str(text)
188
- "##{lit(text)}"
189
- end
190
-
191
- # ---- markdown -> Typst
192
-
193
187
  def markdown(source, inline: false)
194
- root = Archsight::Markdown.document(source).root
195
- out = blocks(root.children)
196
- inline ? out.gsub(/\n\n+/, " #linebreak() ") : out
197
- end
198
-
199
- def blocks(elements)
200
- elements.filter_map { |node| block(node) }.join("\n\n")
201
- end
202
-
203
- def block(node)
204
- case node.type
205
- when :p then inlines(node.children)
206
- when :header then "#heading(level: #{[node.options[:level] + 1, 6].min})[#{inlines(node.children)}]"
207
- when :codeblock then raw(node.value, node.options[:lang])
208
- when :blockquote then "#quote(block: true)[#{blocks(node.children)}]"
209
- when :ul then "#list(#{node.children.map { |li| "[#{blocks(li.children)}]" }.join(", ")})"
210
- when :ol then "#enum(#{node.children.map { |li| "[#{blocks(li.children)}]" }.join(", ")})"
211
- when :hr then "#line(length: 100%)"
212
- when :table then markdown_table(node)
213
- when :blank, :html_element, :xml_comment then nil
214
- else inlines(node.children) unless node.children.empty?
215
- end
216
- end
217
-
218
- def markdown_table(node)
219
- rows = node.children.flat_map(&:children)
220
- cells = rows.map { |tr| tr.children.map { |td| "[#{inlines(td.children)}]" } }
221
- width = cells.map(&:size).max || 1
222
- "#table(columns: #{width}, inset: 5pt, stroke: 0.5pt + luma(200), #{cells.flatten.join(", ")})"
223
- end
224
-
225
- def inlines(elements)
226
- elements.map { |node| inline(node) }.join
227
- end
228
-
229
- def inline(node)
230
- case node.type
231
- when :text then str(node.value)
232
- when :em then "#emph[#{inlines(node.children)}]"
233
- when :strong then "#strong[#{inlines(node.children)}]"
234
- when :codespan then "#raw(#{lit(node.value)})"
235
- when :a then "#link(#{lit(node.attr["href"])})[#{inlines(node.children)}]"
236
- when :br then "#linebreak()"
237
- when :entity then str(node.value.char)
238
- when :smart_quote, :typographic_sym then str(SYMBOLS.fetch(node.value, node.value.to_s))
239
- when :html_element then inlines(node.children)
240
- else inlines(node.children)
241
- end
188
+ Archsight::Pdf::Markdown.new(heading_offset: 1, diagram: method(:diagram)).render(source, inline: inline)
242
189
  end
243
190
  end
244
191
  end
data/lib/archsight/cli.rb CHANGED
@@ -279,6 +279,7 @@ module Archsight
279
279
  option :theme, type: :string, desc: "Spacing/font-size theme (default: the file's own `theme` statement)"
280
280
  option :legend, type: :string, desc: "Legend placement (default: the file's own `legend` statement, else auto)"
281
281
  option :profile, aliases: "-p", type: :boolean, desc: "Print stage timing stats after each render"
282
+ option :resources, aliases: "-r", type: :string, desc: "Directory that `include \"file://...\"` paths are relative to (default: the input's directory)"
282
283
  option :format, type: :boolean, desc: "Re-indent the inputs in place instead of rendering them (-o: write a single input there)"
283
284
  option :check, type: :boolean, desc: "With --format: only list the inputs that are not formatted, exit 1 if any"
284
285
  def diagram(*inputs)
@@ -295,7 +296,7 @@ module Archsight
295
296
 
296
297
  status = diagram::CLI.new.run_with(
297
298
  inputs: inputs, output: options[:output], watch: options[:watch], profile: options[:profile],
298
- format: options[:format], check: options[:check],
299
+ format: options[:format], check: options[:check], resources: options[:resources],
299
300
  style: options[:style], theme: options[:theme], legend: options[:legend],
300
301
  relation_filter: if relation.nil?
301
302
  diagram::Relations::DEFAULT_FILTER
@@ -355,6 +356,43 @@ module Archsight
355
356
  exit 1
356
357
  end
357
358
 
359
+ desc "pdf TARGET", "Export a page or resource as PDF (needs typst)"
360
+ long_desc <<~DESC
361
+ Typesets a page (TARGET is its name) or any other resource (TARGET is Kind/Name) as a PDF with Typst, the same
362
+ document the PDF button of the web UI downloads. The look comes from the Config of type pdf named default (or --template),
363
+ the tags pdf:cover, pdf:toc, pdf:landscape, pdf:draft and pdf:template:<name> steer the layout. The typst command
364
+ is taken from the PATH, or from ARCHSIGHT_PDF_TYPST / `pdf.typst` in ~/.config/archsight/archsight.yaml.
365
+ See the PDF Export page of the documentation.
366
+ DESC
367
+ option :output, aliases: "-o", type: :string, desc: "Output file (default: <name>.pdf in the current directory)"
368
+ option :template, type: :string, desc: "Name of the pdf Config to use (default: the tag or `default`)"
369
+ option :draft, type: :boolean, default: false, desc: "Add the draft watermark"
370
+ def pdf(target)
371
+ configure_resources
372
+ require "archsight/database"
373
+ require "archsight/pdf"
374
+
375
+ db = Archsight::Database.new(Archsight.resources_dir, compute_annotations: false)
376
+ begin
377
+ db.reload!
378
+ rescue Archsight::ResourceError => e
379
+ display_error_with_context(e.to_s)
380
+ exit 1
381
+ end
382
+
383
+ kind, name = target.include?("/") ? target.split("/", 2) : ["Page", target]
384
+ resource = Archsight::Resources[kind] && (db.instance_by_kind(kind, name) || db.instance_by_alias(kind, name))
385
+ abort "Error: #{kind} '#{name}' not found" unless resource
386
+
387
+ document = Archsight::Pdf.document(db, resource, template: options[:template], draft: options[:draft])
388
+ file = options[:output] || document.filename
389
+ File.binwrite(file, Archsight::Pdf.compile(document))
390
+ puts "Wrote #{file}"
391
+ rescue Archsight::Pdf::Error => e
392
+ warn "Error: #{e.message}"
393
+ exit 1
394
+ end
395
+
358
396
  desc "module SUBCOMMAND", "Module analysis commands (e.g. module graph PATH)"
359
397
  subcommand "module", ModuleCLI
360
398
 
@@ -109,11 +109,12 @@ module Archsight
109
109
  matches.first if matches.one?
110
110
  end
111
111
 
112
- # Collect unique annotation values across all instances of a kind
112
+ # Annotation values across all instances of a kind with the number of instances using each,
113
+ # most used first (ties by value): [[value, count], ...]
113
114
  def annotation_values(kind, annotation)
114
115
  instances = instances_by_kind(kind).values
115
- values = instances.flat_map { |inst| Array(annotation.value_for(inst)) }
116
- values.compact.uniq.sort
116
+ counts = instances.flat_map { |inst| Array(annotation.value_for(inst)).compact.uniq }.tally
117
+ counts.sort_by { |value, count| [-count, value.to_s] }
117
118
  end
118
119
 
119
120
  # Get filterable annotations with their values for a kind (excludes empty)
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
  require_relative "."
5
5
  require_relative "formatter"
6
+ require_relative "../helpers/diagram_includes"
6
7
 
7
8
  module Archsight
8
9
  module Diagram
@@ -79,6 +80,8 @@ module Archsight
79
80
  opts.on("-p", "--profile", "Print stage timing stats after each render") do
80
81
  options[:profile] = true
81
82
  end
83
+ opts.on("--resources=DIR", "Directory that include \"file://...\" paths are relative to " \
84
+ "(default: the input file's directory)") { |v| options[:resources] = v }
82
85
  opts.on("--format", "Re-indent the inputs in place instead of rendering them (-o: write a single input there)") do
83
86
  options[:format] = true
84
87
  end
@@ -155,7 +158,9 @@ module Archsight
155
158
  total_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
156
159
 
157
160
  svg = Archsight::Diagram.render(File.read(input), relation_filter: options[:relation_filter], profile: stats,
158
- style: options[:style], theme: options[:theme], legend: options[:legend])
161
+ style: options[:style], theme: options[:theme], legend: options[:legend],
162
+ includes: includes_for(input, options), warnings: warnings = [])
163
+ warnings.each { |message| warn "archsight-diagram: #{input}: #{message}" }
159
164
 
160
165
  write_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
161
166
  File.write(output, svg)
@@ -172,6 +177,11 @@ module Archsight
172
177
  1
173
178
  end
174
179
 
180
+ # Standalone, `include` reads `file://` paths only (there is no model for `archsight://`)
181
+ def includes_for(input, options)
182
+ Helpers::DiagramIncludes.new(nil, resources_dir: options[:resources] || File.dirname(File.expand_path(input)))
183
+ end
184
+
175
185
  def print_stats(stats, total_start)
176
186
  total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - total_start
177
187
  parts = stats.map { |key, seconds| "#{key}=#{format("%.1fms", seconds * 1000)}" }
@@ -5,5 +5,6 @@ module Archsight
5
5
  class Error < StandardError; end
6
6
  class ParseError < Error; end
7
7
  class GraphError < Error; end
8
+ class IncludeError < Error; end
8
9
  end
9
10
  end
@@ -27,13 +27,14 @@ module Archsight
27
27
  NODE = %w[label tint link resource extend].freeze
28
28
  LEAF_KINDS = %i[component application api database queue actor file].freeze
29
29
  LEAF = (NODE + %w[shape]).freeze
30
- CONTAINER = (NODE + %w[gap ranks columns]).freeze
30
+ CONTAINER = (NODE + %w[gap ranks columns anchor]).freeze
31
31
  EDGE = %w[label style relation tint].freeze
32
32
  DATAFLOW = %w[label color].freeze
33
33
 
34
34
  EDGE_STYLES = %w[straight orthogonal].freeze
35
35
  LEAF_EXTEND = %w[true false].freeze
36
36
  CONTAINER_EXTEND = %w[true false height].freeze
37
+ ANCHORS = %w[top bottom left right center].freeze
37
38
 
38
39
  # `40` (pixels), `150%` (of the default), `+20%`/`-50%` (relative);
39
40
  # see `GapSpec`.
@@ -116,6 +117,7 @@ module Archsight
116
117
  when "tint" then check_value!(key, value, Tints.names, where, line)
117
118
  when "ranks" then check_value!(key, value, ranks_modes, where, line)
118
119
  when "extend" then check_value!(key, value, leaf?(block) ? LEAF_EXTEND : CONTAINER_EXTEND, where, line)
120
+ when "anchor" then check_value!(key, value, ANCHORS, where, line)
119
121
  when "gap" then check_format!(key, value, GAP_FORMAT, "a pixel count (40), a percentage (150%) or a signed one (+20%, -50%)", where, line)
120
122
  when "link" then check_link!(value, where, line)
121
123
  when "resource" then check_resource!(value, block, where, line)
@@ -130,11 +130,28 @@ module Archsight
130
130
  # `extend "height"` on a direct child of a `layer` grows that
131
131
  # child's box to its tallest sibling's height, leaving its own
132
132
  # content top-aligned where it already sits. Never inherited --
133
- # only the node carrying the attr is affected.
133
+ # only the node carrying the attr is affected. In a `stack` inside such a
134
+ # grown box it absorbs the extra height instead (see
135
+ # `Expander#grow_height`).
134
136
  def extend_height?
135
137
  attrs["extend"] == "height"
136
138
  end
137
139
 
140
+ # Where this container's content sits when its box grows by `extra`
141
+ # along `axis`: the `anchor` attr (`top`/`bottom` for height,
142
+ # `left`/`right` for width, `center`), else `default`. Returns how
143
+ # far to shift the children (0 = leading edge, `extra` = trailing).
144
+ # Never inherited; a value for the other axis is ignored.
145
+ def anchor_shift(axis, extra, default:)
146
+ value = attrs["anchor"]
147
+ value = default.to_s unless (axis == Axis::HEIGHT ? %w[top bottom center] : %w[left right center]).include?(value)
148
+ case value
149
+ when "top", "left" then 0.0
150
+ when "bottom", "right" then extra.to_f
151
+ else extra / 2.0
152
+ end
153
+ end
154
+
138
155
  def link
139
156
  attrs["link"]
140
157
  end