@yalehwang/plantuml-js 0.2.0 → 0.3.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.
- package/README.md +20 -0
- package/dist/core.d.ts +102 -9
- package/dist/core.js +91 -51
- package/dist/foundation/layers.d.ts +25 -0
- package/dist/foundation/layers.js +101 -0
- package/dist/foundation/layout.d.ts +116 -0
- package/dist/foundation/layout.js +298 -0
- package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
- package/dist/foundation/relation-semantics.d.ts +49 -0
- package/dist/foundation/relation-semantics.js +112 -0
- package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
- package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout/compound.d.ts +59 -0
- package/dist/layout/compound.js +1178 -0
- package/dist/layout/elk-graph.d.ts +36 -0
- package/dist/layout/elk-graph.js +220 -0
- package/dist/layout/elk-result.d.ts +18 -0
- package/dist/layout/elk-result.js +127 -0
- package/dist/layout/elk.d.ts +63 -33
- package/dist/layout/elk.js +215 -321
- package/dist/layout/fanout-trunk.d.ts +18 -0
- package/dist/layout/fanout-trunk.js +380 -0
- package/dist/layout/grid-router.d.ts +69 -0
- package/dist/layout/grid-router.js +615 -0
- package/dist/layout/lane-spread.d.ts +11 -0
- package/dist/layout/lane-spread.js +302 -0
- package/dist/layout/layout-metrics.d.ts +183 -0
- package/dist/layout/layout-metrics.js +766 -0
- package/dist/layout/package-endpoints.d.ts +10 -0
- package/dist/layout/package-endpoints.js +54 -0
- package/dist/layout/placement-strategy.d.ts +98 -0
- package/dist/layout/placement-strategy.js +26 -0
- package/dist/layout/strategy.d.ts +27 -0
- package/dist/layout/strategy.js +19 -0
- package/dist/layout/trunk-lanes.d.ts +29 -0
- package/dist/layout/trunk-lanes.js +308 -0
- package/dist/layout/viz.d.ts +3 -3
- package/dist/layout/viz.js +13 -17
- package/dist/parser.d.ts +27 -1
- package/dist/parser.js +111 -5
- package/dist/render/labels.d.ts +44 -0
- package/dist/render/labels.js +231 -0
- package/dist/render/legend.d.ts +15 -0
- package/dist/render/legend.js +42 -0
- package/dist/render/svg.d.ts +60 -3
- package/dist/render/svg.js +326 -98
- package/dist/render/theme.d.ts +109 -0
- package/dist/render/theme.js +99 -0
- package/dist/serialize.d.ts +32 -0
- package/dist/serialize.js +218 -0
- package/dist/transform.d.ts +55 -1
- package/dist/transform.js +277 -29
- package/package.json +9 -3
- package/dist/layout-metrics.d.ts +0 -42
- package/dist/layout-metrics.js +0 -241
- package/dist/layout.d.ts +0 -75
- package/dist/layout.js +0 -51
- /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
- /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
- /package/dist/{model.js → foundation/model.js} +0 -0
- /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
- /package/dist/{render-dot.js → foundation/render-dot.js} +0 -0
package/README.md
CHANGED
|
@@ -53,12 +53,32 @@ console.log(svg);
|
|
|
53
53
|
|---|---|
|
|
54
54
|
| `createRenderer({ layout })` | Build a renderer. `layout` is `'elk'` (default), `'viz'` (experimental), or a custom `LayoutEngine`. |
|
|
55
55
|
| `renderer.render(puml)` | Parse + layout + emit SVG. Returns `{ svg, diagramType, warnings }`. Throws `RenderError` on structurally invalid puml. |
|
|
56
|
+
| `renderDetailed(pumlOrModel, options?)` | Run the same pipeline as `renderer.render` and return every intermediate: `{ model, layout, svg, layoutMs, hiddenTransitive }`. Accepts a puml string (throws `RenderError` on invalid input) or an already-parsed `PlantUmlModel`. `DetailedRender.layout` carries the **stable** node/package/edge rectangles — see [Stable layout geometry](#stable-layout-geometry-renderdetailed) — so tooling never has to re-read bounding boxes from the SVG. Exported from both `.` and `./elk`. |
|
|
56
57
|
| `parsePlantUml(puml)` | Parse a puml string into a `PlantUmlModel`. |
|
|
57
58
|
| `checkSyntax(puml)` | Validate without throwing. Returns `{ ok, errors, warnings }` of line-accurate `SyntaxDiagnostic`s. |
|
|
58
59
|
| `explain(puml)` | Explain each line. Returns `LineExplanation[]` (line, kind, explanation). |
|
|
59
60
|
| `renderDot(dot, engine)` | Render a raw DOT string to SVG via `@viz-js/viz`. `engine` is `'dot'` (default), `'neato'`, `'fdp'`, `'sfdp'`, `'circo'`, `'twopi'`, `'osage'`, or `'patchwork'`. |
|
|
60
61
|
| `ElkLayoutEngine` / `VizLayoutEngine` | The two built-in `LayoutEngine` implementations, exported for direct use. |
|
|
61
62
|
|
|
63
|
+
### Stable layout geometry (`renderDetailed`)
|
|
64
|
+
|
|
65
|
+
`DetailedRender.layout` is a **stable public contract**: the rectangle fields
|
|
66
|
+
describe exactly what the renderer drew, so a thumbnail, a search jump or a
|
|
67
|
+
selection box can use them directly instead of parsing the SVG. Coordinates are
|
|
68
|
+
relative to the diagram origin (the SVG `viewBox` offset).
|
|
69
|
+
|
|
70
|
+
| `layout` field | Shape | Corresponding SVG hook |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `nodes[]` | `{ id, x, y, width, height }` | `<g class="uml-node" data-entity-id="{id}">` |
|
|
73
|
+
| `packages[]` (engines with container support, e.g. `'elk'`) | `{ id, x, y, width, height }` | `<g class="uml-package" data-package-id="{id}">`; grouped nodes also carry `data-package-id` |
|
|
74
|
+
| `edges[]` | `{ id, source, target, points }` | `<g class="uml-edge" data-edge-source="{source}" data-edge-target="{target}" data-edge-type="…">`; aggregated package corridors add `data-weight`, bundled arrowheads carry `data-edge-target` on `uml-bundle-head` |
|
|
75
|
+
| `width`, `height` | `number` | The `viewBox` extent |
|
|
76
|
+
|
|
77
|
+
`points` is the same polyline the SVG draws, so an edge hit-test can reuse it
|
|
78
|
+
without re-routing. `model` is the diagram actually drawn (after view,
|
|
79
|
+
transforms and detail), and `hiddenTransitive` counts the edges dropped by
|
|
80
|
+
transitive reduction.
|
|
81
|
+
|
|
62
82
|
### Error semantics
|
|
63
83
|
|
|
64
84
|
`createRenderer` fails closed on an unknown layout name (`'elk'`, `'viz'`, or a
|
package/dist/core.d.ts
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
* `src/index.ts` is the full build: it re-exports this core and adds the viz
|
|
7
7
|
* engine + `renderDot`.
|
|
8
8
|
*/
|
|
9
|
-
import { PlantUmlModel } from './model.js';
|
|
9
|
+
import { PlantUmlModel } from './foundation/model.js';
|
|
10
10
|
import { parsePlantUml } from './parser.js';
|
|
11
|
-
import { LayoutEngine } from './layout.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
11
|
+
import { LayoutEngine, LayoutResult, DetailLevel } from './foundation/layout.js';
|
|
12
|
+
import { ThemeOverrides } from './render/theme.js';
|
|
13
|
+
import { ElkLayoutEngine, type ElkLayoutOptions } from './layout/elk.js';
|
|
14
|
+
import { renderSvg, RenderSvgOptions, RenderEngine, RenderFrameOptions, SvgRenderer, SvgRendererOptions } from './render/svg.js';
|
|
14
15
|
import { ViewSpec, TransformOptions } from './transform.js';
|
|
15
|
-
export type { PlantUmlModel, PlantUmlEntity, PlantUmlMember, PlantUmlRelation, PlantUmlPackage, EntityKind, RelationType, } from './model.js';
|
|
16
|
-
export
|
|
16
|
+
export type { PlantUmlModel, PlantUmlEntity, PlantUmlMember, PlantUmlRelation, PlantUmlPackage, EntityKind, RelationType, } from './foundation/model.js';
|
|
17
|
+
export { defaultTheme, resolveTheme } from './render/theme.js';
|
|
18
|
+
export type { Theme, ThemeOverrides } from './render/theme.js';
|
|
19
|
+
export type { DetailLevel } from './foundation/layout.js';
|
|
20
|
+
export type { LayoutEngine, LayoutResult, NodeLayout, EdgeLayout, Point, } from './foundation/layout.js';
|
|
17
21
|
export interface RendererOptions {
|
|
18
22
|
/**
|
|
19
23
|
* `'elk'` (default) | a custom `LayoutEngine` instance. `'viz'` is only
|
|
@@ -21,16 +25,80 @@ export interface RendererOptions {
|
|
|
21
25
|
* fails closed on it.
|
|
22
26
|
*/
|
|
23
27
|
layout?: 'elk' | 'viz' | LayoutEngine;
|
|
28
|
+
/** ELK tuning (e.g. `aspectRatio`); only applies to the `'elk'` engine, ignored by `'viz'` and custom engines. */
|
|
29
|
+
layoutOptions?: ElkLayoutOptions;
|
|
30
|
+
/**
|
|
31
|
+
* Custom `RenderEngine` instance. Default: the built-in [[SvgRenderer]],
|
|
32
|
+
* configured with `theme` and `legend`. When set, this engine owns the
|
|
33
|
+
* rendering configuration, so `theme`/`legend` are not applied to it.
|
|
34
|
+
*/
|
|
35
|
+
renderer?: RenderEngine;
|
|
24
36
|
/** Collapse/expand the diagram before layout (overview, single package, …). */
|
|
25
37
|
view?: ViewSpec;
|
|
26
38
|
/** Extra model transforms applied on top of the view (aggregation, cycles, …). */
|
|
27
39
|
transform?: TransformOptions;
|
|
40
|
+
/** Draw a legend of the visual encodings. Default: on for diagrams with more than 30 entities. */
|
|
41
|
+
legend?: boolean;
|
|
42
|
+
/** Visual theme tokens; unspecified tokens fall back to `defaultTheme`. */
|
|
43
|
+
theme?: ThemeOverrides;
|
|
44
|
+
/**
|
|
45
|
+
* Member detail: `'full'`, `'compact'` (first rows + count) or `'names'`.
|
|
46
|
+
* Default: chosen by entity count (≤40 full, ≤150 compact, else names).
|
|
47
|
+
*/
|
|
48
|
+
detail?: DetailLevel;
|
|
49
|
+
/**
|
|
50
|
+
* `'aggregate'` folds the relations between two packages into one weighted
|
|
51
|
+
* box-to-box corridor edge (originals kept in its `<title>`); `'raw'` draws
|
|
52
|
+
* every edge. Default: `'aggregate'` for diagrams with more than 150 entities.
|
|
53
|
+
*/
|
|
54
|
+
interPackage?: 'aggregate' | 'raw';
|
|
55
|
+
/**
|
|
56
|
+
* Transitive edge reduction (A→B→C hides A→C). `'auto'` (default) reduces only
|
|
57
|
+
* when every relation is a dependency/association and edges outnumber nodes
|
|
58
|
+
* more than 3:1; `'transitive'` always reduces; `'none'` draws every edge.
|
|
59
|
+
* Hidden edges are counted in the legend and in `data-hidden-transitive`.
|
|
60
|
+
*/
|
|
61
|
+
edgeReduction?: 'auto' | 'transitive' | 'none';
|
|
28
62
|
}
|
|
29
63
|
export interface RenderResult {
|
|
30
64
|
svg: string;
|
|
31
65
|
diagramType: string;
|
|
32
66
|
warnings: string[];
|
|
33
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Everything {@link renderDetailed} computed for one diagram: the model that was
|
|
70
|
+
* actually laid out and drawn, the geometry the renderer used, the SVG it
|
|
71
|
+
* emitted, and the reduction bookkeeping.
|
|
72
|
+
*
|
|
73
|
+
* This is a **public, stable API** (available from both the `.` and `./elk`
|
|
74
|
+
* entries). Consumers such as an architecture tab should read geometry from
|
|
75
|
+
* `layout` instead of parsing `svg` back into bounding boxes. In particular the
|
|
76
|
+
* rectangle fields below are a stable contract:
|
|
77
|
+
*
|
|
78
|
+
* - `layout.nodes[i]` — `{ id, x, y, width, height }`; `id` is the same string
|
|
79
|
+
* the SVG puts on `<g class="uml-node" data-entity-id="…">`.
|
|
80
|
+
* - `layout.packages[i]` (present for engines with container support, e.g.
|
|
81
|
+
* `'elk'`) — `{ id, x, y, width, height }`; `id` matches
|
|
82
|
+
* `<g class="uml-package" data-package-id="…">`.
|
|
83
|
+
* - `layout.edges[i]` — `{ id, source, target, points }`; `source`/`target`
|
|
84
|
+
* match the SVG's `data-edge-source`/`data-edge-target` (aggregated package
|
|
85
|
+
* corridors are emitted as `data-edge-source`/`data-edge-target` pairs with
|
|
86
|
+
* `data-weight`), and `points` is the same polyline the SVG draws.
|
|
87
|
+
*
|
|
88
|
+
* `x`/`y` are relative to the diagram origin (the SVG's `viewBox` offset), so
|
|
89
|
+
* they can be translated into a thumbnail directly.
|
|
90
|
+
*/
|
|
91
|
+
export interface DetailedRender {
|
|
92
|
+
/** The model actually laid out and drawn (after view, transforms and detail). */
|
|
93
|
+
model: PlantUmlModel;
|
|
94
|
+
/** Geometry actually used by the renderer; its node/package/edge rectangles are a stable contract (see above). */
|
|
95
|
+
layout: LayoutResult;
|
|
96
|
+
svg: string;
|
|
97
|
+
/** Layout engine wall time in milliseconds (excludes transforms, SVG and any rasterisation). */
|
|
98
|
+
layoutMs: number;
|
|
99
|
+
/** Edges hidden by transitive reduction (0 when it did not apply). */
|
|
100
|
+
hiddenTransitive: number;
|
|
101
|
+
}
|
|
34
102
|
export interface Renderer {
|
|
35
103
|
render(puml: string): Promise<RenderResult>;
|
|
36
104
|
}
|
|
@@ -54,6 +122,23 @@ export declare class RenderError extends Error {
|
|
|
54
122
|
constructor(diagnostics: SyntaxDiagnostic[]);
|
|
55
123
|
}
|
|
56
124
|
export declare function createRenderer(options?: RendererOptions): Renderer;
|
|
125
|
+
/**
|
|
126
|
+
* Render one diagram through the exact pipeline `createRenderer` uses
|
|
127
|
+
* (view/transforms → detail → layout → svg) and return every intermediate, so
|
|
128
|
+
* tooling can read real geometry instead of reverse-engineering the SVG.
|
|
129
|
+
*
|
|
130
|
+
* Accepts either a puml string (the counterpart of
|
|
131
|
+
* `createRenderer(options).render(puml)`, with the same fail-closed
|
|
132
|
+
* {@link RenderError} on invalid input) or an already-parsed
|
|
133
|
+
* {@link PlantUmlModel} (used by the sample harness, which parses first to also
|
|
134
|
+
* read parse diagnostics).
|
|
135
|
+
*
|
|
136
|
+
* Stable public API, exported from both the `.` and `./elk` package entries.
|
|
137
|
+
* The returned {@link DetailedRender.layout} rectangles are the stable contract
|
|
138
|
+
* documented on {@link DetailedRender}.
|
|
139
|
+
*/
|
|
140
|
+
export declare function renderDetailed(input: string, options?: RendererOptions): Promise<DetailedRender>;
|
|
141
|
+
export declare function renderDetailed(input: PlantUmlModel, options?: RendererOptions): Promise<DetailedRender>;
|
|
57
142
|
/**
|
|
58
143
|
* Entity count at or above which the default full-graph render enables hub
|
|
59
144
|
* summarization. Smaller models stay untouched so a hand-authored diagram is
|
|
@@ -76,6 +161,12 @@ export declare const FULL_GRAPH_HUB_IN_DEGREE_THRESHOLD = 6;
|
|
|
76
161
|
* drawing of the hub's incoming edges.
|
|
77
162
|
*/
|
|
78
163
|
export declare function transformFullGraph(model: PlantUmlModel, overrides?: TransformOptions): PlantUmlModel;
|
|
164
|
+
/**
|
|
165
|
+
* Apply a view, then the same default transforms as the full graph (computed on
|
|
166
|
+
* the *viewed* model, so a large single-package view summarizes its hubs too).
|
|
167
|
+
* `overrides` wins over the defaults.
|
|
168
|
+
*/
|
|
169
|
+
export declare function transformView(model: PlantUmlModel, view: ViewSpec, overrides?: TransformOptions): PlantUmlModel;
|
|
79
170
|
/**
|
|
80
171
|
* Validate a puml string without throwing. Returns structured diagnostics for
|
|
81
172
|
* structurally invalid input (content outside a diagram, unbalanced
|
|
@@ -99,9 +190,11 @@ export interface LineExplanation {
|
|
|
99
190
|
* human-readable description. Purely descriptive — it never mutates or throws.
|
|
100
191
|
*/
|
|
101
192
|
export declare function explain(puml: string): LineExplanation[];
|
|
102
|
-
export { parsePlantUml, ElkLayoutEngine, renderSvg };
|
|
103
|
-
export {
|
|
104
|
-
export type {
|
|
193
|
+
export { parsePlantUml, ElkLayoutEngine, renderSvg, SvgRenderer };
|
|
194
|
+
export { toPlantUml } from './serialize.js';
|
|
195
|
+
export type { RenderSvgOptions, RenderEngine, RenderFrameOptions, SvgRendererOptions };
|
|
196
|
+
export { computeLayoutMetrics } from './layout/layout-metrics.js';
|
|
197
|
+
export type { LayoutMetrics } from './layout/layout-metrics.js';
|
|
105
198
|
export { applyView, transformModel, aggregateRelations, transitiveReduce, summarizeHubs, markCycles, groupByPathPrefix, } from './transform.js';
|
|
106
199
|
export type { ViewSpec, TransformOptions } from './transform.js';
|
|
107
200
|
export { archguardToPlantUml, archguardAliasMap, pathName, displayName, ARCHGUARD_RELATION_ARROWS, } from './archguard.js';
|
package/dist/core.js
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* `src/index.ts` is the full build: it re-exports this core and adds the viz
|
|
7
7
|
* engine + `renderDot`.
|
|
8
8
|
*/
|
|
9
|
-
import { parsePlantUml } from './parser.js';
|
|
9
|
+
import { parsePlantUml, parseWithDiagnostics, ENTITY_LINE, ENTITY_KEYWORD, extractDeclaredName, stripComment } from './parser.js';
|
|
10
|
+
import { applyDetail } from './foundation/layout.js';
|
|
10
11
|
import { ElkLayoutEngine } from './layout/elk.js';
|
|
11
|
-
import { renderSvg } from './render/svg.js';
|
|
12
|
-
import { applyView, transformModel } from './transform.js';
|
|
12
|
+
import { renderSvg, SvgRenderer, } from './render/svg.js';
|
|
13
|
+
import { applyView, transformModel, transformModelDetailed, shouldAutoReduce, INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD } from './transform.js';
|
|
14
|
+
export { defaultTheme, resolveTheme } from './render/theme.js';
|
|
13
15
|
/** Error thrown by {@link createRenderer}'s `render` on structurally invalid puml. */
|
|
14
16
|
export class RenderError extends Error {
|
|
15
17
|
diagnostics;
|
|
@@ -20,9 +22,9 @@ export class RenderError extends Error {
|
|
|
20
22
|
this.diagnostics = diagnostics;
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
|
-
function resolveEngine(layout) {
|
|
25
|
+
function resolveEngine(layout, layoutOptions) {
|
|
24
26
|
if (layout === undefined || layout === 'elk')
|
|
25
|
-
return new ElkLayoutEngine();
|
|
27
|
+
return new ElkLayoutEngine(layoutOptions);
|
|
26
28
|
if (typeof layout === 'string') {
|
|
27
29
|
throw new Error(`unknown layout engine ${JSON.stringify(layout)} (expected 'elk' or a LayoutEngine instance; ` +
|
|
28
30
|
`'viz' is only available from the full build '@yalehwang/plantuml-js')`);
|
|
@@ -32,8 +34,25 @@ function resolveEngine(layout) {
|
|
|
32
34
|
}
|
|
33
35
|
return layout;
|
|
34
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the render engine the same way [[resolveEngine]] resolves layout:
|
|
39
|
+
* the injected instance wins; otherwise the built-in [[SvgRenderer]] takes the
|
|
40
|
+
* renderer-wide static configuration (`theme`, `legend`). Structural validation
|
|
41
|
+
* fails closed instead of rendering an empty SVG.
|
|
42
|
+
*/
|
|
43
|
+
function resolveRenderer(options) {
|
|
44
|
+
const renderer = options.renderer;
|
|
45
|
+
if (renderer === undefined) {
|
|
46
|
+
return new SvgRenderer({ legend: options.legend, theme: options.theme });
|
|
47
|
+
}
|
|
48
|
+
if (typeof renderer.render !== 'function') {
|
|
49
|
+
throw new Error('invalid RenderEngine: a custom engine must expose a render(model, layout) method');
|
|
50
|
+
}
|
|
51
|
+
return renderer;
|
|
52
|
+
}
|
|
35
53
|
export function createRenderer(options = {}) {
|
|
36
|
-
const engine = resolveEngine(options.layout);
|
|
54
|
+
const engine = resolveEngine(options.layout, options.layoutOptions);
|
|
55
|
+
const renderEngine = resolveRenderer(options);
|
|
37
56
|
return {
|
|
38
57
|
async render(puml) {
|
|
39
58
|
// Fail closed on structurally invalid puml: checkSyntax reports instead of
|
|
@@ -41,14 +60,35 @@ export function createRenderer(options = {}) {
|
|
|
41
60
|
const syntax = checkSyntax(puml);
|
|
42
61
|
if (!syntax.ok)
|
|
43
62
|
throw new RenderError(syntax.errors);
|
|
44
|
-
const parsed =
|
|
45
|
-
const model =
|
|
46
|
-
|
|
47
|
-
const svg = renderSvg(model, layout);
|
|
48
|
-
return { svg, diagramType: model.diagramType, warnings: [] };
|
|
63
|
+
const { model: parsed, diagnostics } = parseWithDiagnostics(puml);
|
|
64
|
+
const { svg, model } = await renderParsed(parsed, options, engine, renderEngine);
|
|
65
|
+
return { svg, diagramType: model.diagramType, warnings: diagnostics.map((d) => `line ${d.line}: ${d.message}`) };
|
|
49
66
|
},
|
|
50
67
|
};
|
|
51
68
|
}
|
|
69
|
+
async function renderParsed(parsed, options, engine, renderEngine) {
|
|
70
|
+
const { model: transformed, hiddenTransitive } = transformForRender(parsed, options);
|
|
71
|
+
const model = applyDetail(transformed, options.detail);
|
|
72
|
+
const started = performance.now();
|
|
73
|
+
const layout = await engine.layout(model);
|
|
74
|
+
const layoutMs = Math.round((performance.now() - started) * 100) / 100;
|
|
75
|
+
const svg = renderEngine.render(model, layout, { hiddenTransitive });
|
|
76
|
+
return { model, layout, svg, layoutMs, hiddenTransitive };
|
|
77
|
+
}
|
|
78
|
+
export async function renderDetailed(input, options = {}) {
|
|
79
|
+
const parsed = typeof input === 'string' ? parseForDetailed(input) : input;
|
|
80
|
+
return renderParsed(parsed, options, resolveEngine(options.layout, options.layoutOptions), resolveRenderer(options));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse the string form of {@link renderDetailed} with the same fail-closed
|
|
84
|
+
* semantics as `createRenderer().render` (structured `RenderError`).
|
|
85
|
+
*/
|
|
86
|
+
function parseForDetailed(puml) {
|
|
87
|
+
const syntax = checkSyntax(puml);
|
|
88
|
+
if (!syntax.ok)
|
|
89
|
+
throw new RenderError(syntax.errors);
|
|
90
|
+
return parseWithDiagnostics(puml).model;
|
|
91
|
+
}
|
|
52
92
|
/**
|
|
53
93
|
* Apply the configured view and transforms. A view collapses/expands first,
|
|
54
94
|
* then the transform pipeline runs with the view's defaults (aggregation +
|
|
@@ -59,13 +99,22 @@ export function createRenderer(options = {}) {
|
|
|
59
99
|
* unreadable arrow cluster.
|
|
60
100
|
*/
|
|
61
101
|
function transformForRender(model, options) {
|
|
102
|
+
const overrides = {
|
|
103
|
+
...(options.interPackage ? { interPackage: options.interPackage } : {}),
|
|
104
|
+
...options.transform,
|
|
105
|
+
};
|
|
106
|
+
const mode = options.edgeReduction ?? 'auto';
|
|
107
|
+
const reduction = (m) => overrides.transitiveReduction !== undefined || mode === 'none'
|
|
108
|
+
? {}
|
|
109
|
+
: { transitiveReduction: mode === 'transitive' || shouldAutoReduce(m) };
|
|
62
110
|
if (options.view) {
|
|
63
111
|
const viewed = applyView(model, options.view);
|
|
64
|
-
return
|
|
112
|
+
return transformModelDetailed(viewed, { ...defaultTransformOptions(viewed), ...reduction(viewed), ...overrides });
|
|
113
|
+
}
|
|
114
|
+
if (options.transform) {
|
|
115
|
+
return transformModelDetailed(model, { interPackage: defaultInterPackage(model), ...reduction(model), ...overrides });
|
|
65
116
|
}
|
|
66
|
-
|
|
67
|
-
return transformModel(model, options.transform);
|
|
68
|
-
return transformFullGraph(model);
|
|
117
|
+
return transformModelDetailed(model, { ...defaultTransformOptions(model), ...reduction(model), ...overrides });
|
|
69
118
|
}
|
|
70
119
|
/**
|
|
71
120
|
* Entity count at or above which the default full-graph render enables hub
|
|
@@ -89,14 +138,27 @@ export const FULL_GRAPH_HUB_IN_DEGREE_THRESHOLD = 6;
|
|
|
89
138
|
* drawing of the hub's incoming edges.
|
|
90
139
|
*/
|
|
91
140
|
export function transformFullGraph(model, overrides = {}) {
|
|
92
|
-
|
|
141
|
+
return transformModel(model, { ...defaultTransformOptions(model), ...overrides });
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Apply a view, then the same default transforms as the full graph (computed on
|
|
145
|
+
* the *viewed* model, so a large single-package view summarizes its hubs too).
|
|
146
|
+
* `overrides` wins over the defaults.
|
|
147
|
+
*/
|
|
148
|
+
export function transformView(model, view, overrides = {}) {
|
|
149
|
+
const viewed = applyView(model, view);
|
|
150
|
+
return transformModel(viewed, { ...defaultTransformOptions(viewed), ...overrides });
|
|
151
|
+
}
|
|
152
|
+
function defaultInterPackage(model) {
|
|
153
|
+
return model.entities.length > INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD ? 'aggregate' : 'raw';
|
|
154
|
+
}
|
|
155
|
+
function defaultTransformOptions(model) {
|
|
156
|
+
const options = { aggregate: true, cycles: true, interPackage: defaultInterPackage(model) };
|
|
93
157
|
if (model.entities.length > FULL_GRAPH_HUB_NODE_THRESHOLD) {
|
|
94
158
|
options.hubThreshold = FULL_GRAPH_HUB_IN_DEGREE_THRESHOLD;
|
|
95
159
|
}
|
|
96
|
-
return
|
|
160
|
+
return options;
|
|
97
161
|
}
|
|
98
|
-
const ENTITY_DECL = /^(?:abstract\s+)?(?:class|interface|enum)\s+(.+)$/i;
|
|
99
|
-
const ENTITY_KEYWORD = /^(?:abstract\s+)?(?:class|interface|enum)\b/i;
|
|
100
162
|
/**
|
|
101
163
|
* Validate a puml string without throwing. Returns structured diagnostics for
|
|
102
164
|
* structurally invalid input (content outside a diagram, unbalanced
|
|
@@ -153,8 +215,8 @@ export function checkSyntax(puml) {
|
|
|
153
215
|
if (code === '')
|
|
154
216
|
continue;
|
|
155
217
|
if (ENTITY_KEYWORD.test(code)) {
|
|
156
|
-
const match =
|
|
157
|
-
const name = match ? extractDeclaredName(match[
|
|
218
|
+
const match = ENTITY_LINE.exec(code);
|
|
219
|
+
const name = match ? extractDeclaredName(match[3]) : null;
|
|
158
220
|
if (name === null) {
|
|
159
221
|
errors.push({
|
|
160
222
|
line: lineNo,
|
|
@@ -164,6 +226,9 @@ export function checkSyntax(puml) {
|
|
|
164
226
|
}
|
|
165
227
|
}
|
|
166
228
|
}
|
|
229
|
+
for (const d of parseWithDiagnostics(puml).diagnostics) {
|
|
230
|
+
warnings.push({ line: d.line, severity: 'warning', message: d.message });
|
|
231
|
+
}
|
|
167
232
|
if (sawStart && inDiagram) {
|
|
168
233
|
errors.push({
|
|
169
234
|
line: lines.length,
|
|
@@ -204,8 +269,8 @@ function classifyLine(raw) {
|
|
|
204
269
|
if (code === '')
|
|
205
270
|
return { kind: 'comment', explanation: 'PlantUML comment' };
|
|
206
271
|
if (ENTITY_KEYWORD.test(code)) {
|
|
207
|
-
const match =
|
|
208
|
-
const name = match ? extractDeclaredName(match[
|
|
272
|
+
const match = ENTITY_LINE.exec(code);
|
|
273
|
+
const name = match ? extractDeclaredName(match[3]) : null;
|
|
209
274
|
if (name === null) {
|
|
210
275
|
return { kind: 'entity', explanation: 'malformed entity declaration' };
|
|
211
276
|
}
|
|
@@ -228,33 +293,8 @@ function classifyLine(raw) {
|
|
|
228
293
|
};
|
|
229
294
|
return { kind: 'unknown', explanation: 'unrecognised line (ignored)' };
|
|
230
295
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
* tolerates a trailing `as <alias>`. Returns null when the name is missing or
|
|
235
|
-
* the quoted name is unterminated.
|
|
236
|
-
*/
|
|
237
|
-
function extractDeclaredName(rest) {
|
|
238
|
-
const asIndex = rest.search(/\s+as\s+\S+\s*$/i);
|
|
239
|
-
const core = asIndex === -1 ? rest.trim() : rest.slice(0, asIndex).trim();
|
|
240
|
-
if (core === '')
|
|
241
|
-
return null;
|
|
242
|
-
if (core.startsWith('"')) {
|
|
243
|
-
const close = core.indexOf('"', 1);
|
|
244
|
-
if (close === -1)
|
|
245
|
-
return null;
|
|
246
|
-
return core.slice(1, close);
|
|
247
|
-
}
|
|
248
|
-
return core.split(/\s+/)[0] || null;
|
|
249
|
-
}
|
|
250
|
-
/** Strip PlantUML `'` line comments (a leading `'` or a trailing inline `'`). */
|
|
251
|
-
function stripComment(line) {
|
|
252
|
-
const idx = line.indexOf("'");
|
|
253
|
-
if (idx === -1)
|
|
254
|
-
return line.trim();
|
|
255
|
-
return line.slice(0, idx).trim();
|
|
256
|
-
}
|
|
257
|
-
export { parsePlantUml, ElkLayoutEngine, renderSvg };
|
|
258
|
-
export { computeLayoutMetrics } from './layout-metrics.js';
|
|
296
|
+
export { parsePlantUml, ElkLayoutEngine, renderSvg, SvgRenderer };
|
|
297
|
+
export { toPlantUml } from './serialize.js';
|
|
298
|
+
export { computeLayoutMetrics } from './layout/layout-metrics.js';
|
|
259
299
|
export { applyView, transformModel, aggregateRelations, transitiveReduce, summarizeHubs, markCycles, groupByPathPrefix, } from './transform.js';
|
|
260
300
|
export { archguardToPlantUml, archguardAliasMap, pathName, displayName, ARCHGUARD_RELATION_ARROWS, } from './archguard.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic layer resolution for the ELK engine: which layer a package belongs
|
|
3
|
+
* to, how layers are ordered, and which ELK partition each package lands in.
|
|
4
|
+
* Pure functions over the model; no ELK types involved.
|
|
5
|
+
*/
|
|
6
|
+
import { PlantUmlModel, PlantUmlPackage } from './model.js';
|
|
7
|
+
/** Resolve a package's semantic layer from its explicit field or its name. */
|
|
8
|
+
export declare function packageLayer(pkg: PlantUmlPackage): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Order semantic layers by dependency topology instead of puml declaration
|
|
11
|
+
* order: a layer that depends on another (`A ..> B` across layers) sits *above*
|
|
12
|
+
* its dependency, so entry layers (Tests, Examples) stack over the base they
|
|
13
|
+
* consume (Src) and independent layers (Templates) fall out of the sandwich.
|
|
14
|
+
*
|
|
15
|
+
* A Kahn topological sort places in-degree-zero layers (nothing depends on
|
|
16
|
+
* them) first, breaking ties in declaration order for determinism. Layers left
|
|
17
|
+
* over because of a dependency cycle are appended in declaration order, so a
|
|
18
|
+
* cyclic graph still yields a stable (if not strictly topological) order; the
|
|
19
|
+
* renderer marks the offending back-edges as feedback arcs.
|
|
20
|
+
*/
|
|
21
|
+
export declare function topologicalLayerOrder(model: PlantUmlModel): string[];
|
|
22
|
+
/** Ordered, de-duplicated list of semantic layers (dependency topology by default). */
|
|
23
|
+
export declare function resolveLayerOrder(model: PlantUmlModel, layerOrder?: string[]): string[];
|
|
24
|
+
/** Map each package to its partition index (layers first, unlayered last). */
|
|
25
|
+
export declare function assignPartitions(model: PlantUmlModel, layers: string[]): Map<string, number>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic layer resolution for the ELK engine: which layer a package belongs
|
|
3
|
+
* to, how layers are ordered, and which ELK partition each package lands in.
|
|
4
|
+
* Pure functions over the model; no ELK types involved.
|
|
5
|
+
*/
|
|
6
|
+
/** Name convention: `"X Layer"` declares a semantic layer `X`. */
|
|
7
|
+
const LAYER_SUFFIX = /^(.*)\s+Layer$/i;
|
|
8
|
+
/** Resolve a package's semantic layer from its explicit field or its name. */
|
|
9
|
+
export function packageLayer(pkg) {
|
|
10
|
+
if (pkg.layer !== undefined)
|
|
11
|
+
return pkg.layer;
|
|
12
|
+
const m = LAYER_SUFFIX.exec(pkg.name);
|
|
13
|
+
if (!m)
|
|
14
|
+
return undefined;
|
|
15
|
+
const core = m[1].trim();
|
|
16
|
+
return core === '' ? undefined : core;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Order semantic layers by dependency topology instead of puml declaration
|
|
20
|
+
* order: a layer that depends on another (`A ..> B` across layers) sits *above*
|
|
21
|
+
* its dependency, so entry layers (Tests, Examples) stack over the base they
|
|
22
|
+
* consume (Src) and independent layers (Templates) fall out of the sandwich.
|
|
23
|
+
*
|
|
24
|
+
* A Kahn topological sort places in-degree-zero layers (nothing depends on
|
|
25
|
+
* them) first, breaking ties in declaration order for determinism. Layers left
|
|
26
|
+
* over because of a dependency cycle are appended in declaration order, so a
|
|
27
|
+
* cyclic graph still yields a stable (if not strictly topological) order; the
|
|
28
|
+
* renderer marks the offending back-edges as feedback arcs.
|
|
29
|
+
*/
|
|
30
|
+
export function topologicalLayerOrder(model) {
|
|
31
|
+
const declared = [];
|
|
32
|
+
for (const pkg of model.packages) {
|
|
33
|
+
const layer = packageLayer(pkg);
|
|
34
|
+
if (layer !== undefined && !declared.includes(layer))
|
|
35
|
+
declared.push(layer);
|
|
36
|
+
}
|
|
37
|
+
if (declared.length <= 1)
|
|
38
|
+
return declared;
|
|
39
|
+
const pkgById = new Map(model.packages.map((p) => [p.id, p]));
|
|
40
|
+
const layerOf = new Map();
|
|
41
|
+
for (const entity of model.entities) {
|
|
42
|
+
const pkg = entity.packageId ? pkgById.get(entity.packageId) : undefined;
|
|
43
|
+
layerOf.set(entity.id, pkg ? packageLayer(pkg) : undefined);
|
|
44
|
+
}
|
|
45
|
+
const dependsOn = new Map();
|
|
46
|
+
const dependents = new Map();
|
|
47
|
+
for (const layer of declared) {
|
|
48
|
+
dependsOn.set(layer, new Set());
|
|
49
|
+
dependents.set(layer, 0);
|
|
50
|
+
}
|
|
51
|
+
for (const relation of model.relations) {
|
|
52
|
+
const source = layerOf.get(relation.source);
|
|
53
|
+
const target = layerOf.get(relation.target);
|
|
54
|
+
if (source === undefined || target === undefined || source === target)
|
|
55
|
+
continue;
|
|
56
|
+
const set = dependsOn.get(source);
|
|
57
|
+
if (!set.has(target)) {
|
|
58
|
+
set.add(target);
|
|
59
|
+
dependents.set(target, (dependents.get(target) ?? 0) + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const order = [];
|
|
63
|
+
const done = new Set();
|
|
64
|
+
const queue = declared.filter((layer) => (dependents.get(layer) ?? 0) === 0);
|
|
65
|
+
for (let i = 0; i < queue.length; i++) {
|
|
66
|
+
const layer = queue[i];
|
|
67
|
+
order.push(layer);
|
|
68
|
+
done.add(layer);
|
|
69
|
+
for (const dep of dependsOn.get(layer) ?? []) {
|
|
70
|
+
const remaining = (dependents.get(dep) ?? 0) - 1;
|
|
71
|
+
dependents.set(dep, remaining);
|
|
72
|
+
if (remaining === 0 && !done.has(dep))
|
|
73
|
+
queue.push(dep);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Cyclic remainder: keep declaration order so the output is stable.
|
|
77
|
+
for (const layer of declared) {
|
|
78
|
+
if (!done.has(layer))
|
|
79
|
+
order.push(layer);
|
|
80
|
+
}
|
|
81
|
+
return order;
|
|
82
|
+
}
|
|
83
|
+
/** Ordered, de-duplicated list of semantic layers (dependency topology by default). */
|
|
84
|
+
export function resolveLayerOrder(model, layerOrder) {
|
|
85
|
+
if (layerOrder) {
|
|
86
|
+
return layerOrder.filter((layer, i, all) => all.indexOf(layer) === i);
|
|
87
|
+
}
|
|
88
|
+
return topologicalLayerOrder(model);
|
|
89
|
+
}
|
|
90
|
+
/** Map each package to its partition index (layers first, unlayered last). */
|
|
91
|
+
export function assignPartitions(model, layers) {
|
|
92
|
+
const partitions = new Map();
|
|
93
|
+
if (layers.length === 0)
|
|
94
|
+
return partitions;
|
|
95
|
+
for (const pkg of model.packages) {
|
|
96
|
+
const layer = packageLayer(pkg);
|
|
97
|
+
const index = layer !== undefined ? layers.indexOf(layer) : -1;
|
|
98
|
+
partitions.set(pkg.id, index >= 0 ? index : layers.length);
|
|
99
|
+
}
|
|
100
|
+
return partitions;
|
|
101
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LayoutEngine — the pluggable layout contract. A LayoutEngine is a pure
|
|
3
|
+
* function from PlantUmlModel to concrete positions; the parser never talks
|
|
4
|
+
* to a layout engine and the renderer never talks to the parser, so swapping
|
|
5
|
+
* engines (elkjs, @viz-js/viz, a custom tuner) is an injection, not a rewrite.
|
|
6
|
+
*/
|
|
7
|
+
import { PlantUmlModel, PlantUmlEntity } from './model.js';
|
|
8
|
+
export interface Point {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
}
|
|
12
|
+
export interface NodeLayout {
|
|
13
|
+
id: string;
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
export interface EdgeLayout {
|
|
20
|
+
id: string;
|
|
21
|
+
source: string;
|
|
22
|
+
target: string;
|
|
23
|
+
points: Point[];
|
|
24
|
+
}
|
|
25
|
+
export interface PackageLayout {
|
|
26
|
+
id: string;
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
}
|
|
32
|
+
export interface LayoutResult {
|
|
33
|
+
nodes: NodeLayout[];
|
|
34
|
+
edges: EdgeLayout[];
|
|
35
|
+
/** Package frames; engines without container support may omit this. */
|
|
36
|
+
packages?: PackageLayout[];
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
}
|
|
40
|
+
export interface LayoutEngine {
|
|
41
|
+
layout(model: PlantUmlModel): Promise<LayoutResult>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Shared drawing metrics. The renderer reads these so entity sizing and the
|
|
45
|
+
* emitted SVG stay in lockstep: the font family and size are bound to the same
|
|
46
|
+
* monospace advance [[../text-measure]] measures with.
|
|
47
|
+
*/
|
|
48
|
+
export declare const METRICS: {
|
|
49
|
+
readonly fontFamily: "monospace";
|
|
50
|
+
readonly fontSize: 13;
|
|
51
|
+
readonly lineHeight: 18;
|
|
52
|
+
readonly paddingX: 10;
|
|
53
|
+
readonly paddingY: 8;
|
|
54
|
+
readonly minWidth: 60;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Package label-tab geometry, shared by the renderer and the layout-metrics
|
|
58
|
+
* overlap counter so a drawn tab and a measured tab are the same rectangle.
|
|
59
|
+
*/
|
|
60
|
+
export declare const PACKAGE_LABEL: {
|
|
61
|
+
readonly tabHeight: 20;
|
|
62
|
+
readonly fontSize: 12;
|
|
63
|
+
readonly tabPadding: 8;
|
|
64
|
+
};
|
|
65
|
+
/** Spacing grid (px). Entity box sizes snap up to a multiple of it. */
|
|
66
|
+
export declare const GRID = 8;
|
|
67
|
+
export declare function snapUp(n: number, grid?: number): number;
|
|
68
|
+
/** Reconstruct the name drawn in the title compartment, generics expanded. */
|
|
69
|
+
export declare function displayName(entity: PlantUmlEntity): string;
|
|
70
|
+
/**
|
|
71
|
+
* Approximate the on-canvas size of an entity box. The title plus every member
|
|
72
|
+
* line contributes one row; the width is the longest rendered line measured
|
|
73
|
+
* through the text table plus padding, never below MIN_WIDTH.
|
|
74
|
+
*/
|
|
75
|
+
export declare function estimateEntitySize(entity: PlantUmlEntity): {
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Level of member detail drawn per entity: every member (`full`), the first
|
|
81
|
+
* few plus a count (`compact`), or just the class name (`names`).
|
|
82
|
+
*/
|
|
83
|
+
export type DetailLevel = 'full' | 'compact' | 'names';
|
|
84
|
+
/** Entity count up to which `full` is chosen automatically. */
|
|
85
|
+
export declare const DETAIL_FULL_MAX_ENTITIES = 40;
|
|
86
|
+
/** Entity count up to which `compact` is chosen automatically (beyond: `names`). */
|
|
87
|
+
export declare const DETAIL_COMPACT_MAX_ENTITIES = 150;
|
|
88
|
+
/** Member rows kept per entity in `compact` mode. */
|
|
89
|
+
export declare const COMPACT_MEMBER_ROWS = 4;
|
|
90
|
+
export declare function autoDetail(entityCount: number): DetailLevel;
|
|
91
|
+
/**
|
|
92
|
+
* Reduce member rows to the requested detail level *before* layout, so boxes
|
|
93
|
+
* are sized for what is actually drawn. `undefined` picks by entity count.
|
|
94
|
+
*/
|
|
95
|
+
export declare function applyDetail(model: PlantUmlModel, detail?: DetailLevel): PlantUmlModel;
|
|
96
|
+
/**
|
|
97
|
+
* Group nodes into rows: within one package (or the top level), nodes whose
|
|
98
|
+
* vertical extents overlap belong to the same layered-layout rank. Returns
|
|
99
|
+
* arrays of node ids, each sorted top-to-bottom.
|
|
100
|
+
*/
|
|
101
|
+
export declare function layoutRows(model: PlantUmlModel, layout: LayoutResult): NodeLayout[][];
|
|
102
|
+
/**
|
|
103
|
+
* Top-align the nodes of every row (never moving a node down or out of its
|
|
104
|
+
* package) and keep edges attached: an endpoint on a moved node shifts with it,
|
|
105
|
+
* dragging along the next bend when the first segment is horizontal, so
|
|
106
|
+
* orthogonal routes stay orthogonal. Mutates and returns `layout`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function alignRows(model: PlantUmlModel, layout: LayoutResult): LayoutResult;
|
|
109
|
+
/**
|
|
110
|
+
* Top-align an isolated package frame (no edge touches any entity inside it)
|
|
111
|
+
* with the topmost side-by-side neighbour frame, so a lone package sits level
|
|
112
|
+
* with its neighbour instead of floating at its own offset. Only moves up, and
|
|
113
|
+
* only when the frame (with its contents) stays clear of everything else.
|
|
114
|
+
* Mutates and returns `layout`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function alignIsolatedFrames(model: PlantUmlModel, layout: LayoutResult): LayoutResult;
|