@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.
Files changed (64) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +102 -9
  3. package/dist/core.js +91 -51
  4. package/dist/foundation/layers.d.ts +25 -0
  5. package/dist/foundation/layers.js +101 -0
  6. package/dist/foundation/layout.d.ts +116 -0
  7. package/dist/foundation/layout.js +298 -0
  8. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  9. package/dist/foundation/relation-semantics.d.ts +49 -0
  10. package/dist/foundation/relation-semantics.js +112 -0
  11. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  12. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/layout/compound.d.ts +59 -0
  16. package/dist/layout/compound.js +1178 -0
  17. package/dist/layout/elk-graph.d.ts +36 -0
  18. package/dist/layout/elk-graph.js +220 -0
  19. package/dist/layout/elk-result.d.ts +18 -0
  20. package/dist/layout/elk-result.js +127 -0
  21. package/dist/layout/elk.d.ts +63 -33
  22. package/dist/layout/elk.js +215 -321
  23. package/dist/layout/fanout-trunk.d.ts +18 -0
  24. package/dist/layout/fanout-trunk.js +380 -0
  25. package/dist/layout/grid-router.d.ts +69 -0
  26. package/dist/layout/grid-router.js +615 -0
  27. package/dist/layout/lane-spread.d.ts +11 -0
  28. package/dist/layout/lane-spread.js +302 -0
  29. package/dist/layout/layout-metrics.d.ts +183 -0
  30. package/dist/layout/layout-metrics.js +766 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/parser.d.ts +27 -1
  42. package/dist/parser.js +111 -5
  43. package/dist/render/labels.d.ts +44 -0
  44. package/dist/render/labels.js +231 -0
  45. package/dist/render/legend.d.ts +15 -0
  46. package/dist/render/legend.js +42 -0
  47. package/dist/render/svg.d.ts +60 -3
  48. package/dist/render/svg.js +326 -98
  49. package/dist/render/theme.d.ts +109 -0
  50. package/dist/render/theme.js +99 -0
  51. package/dist/serialize.d.ts +32 -0
  52. package/dist/serialize.js +218 -0
  53. package/dist/transform.d.ts +55 -1
  54. package/dist/transform.js +277 -29
  55. package/package.json +9 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  61. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  62. /package/dist/{model.js → foundation/model.js} +0 -0
  63. /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
  64. /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 { ElkLayoutEngine } from './layout/elk.js';
13
- import { renderSvg } from './render/svg.js';
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 type { LayoutEngine, LayoutResult, NodeLayout, EdgeLayout, Point, } from './layout.js';
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 { computeLayoutMetrics } from './layout-metrics.js';
104
- export type { LayoutMetrics } from './layout-metrics.js';
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 = parsePlantUml(puml);
45
- const model = transformForRender(parsed, options);
46
- const layout = await engine.layout(model);
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 transformModel(viewed, { aggregate: true, cycles: true, ...options.transform });
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
- if (options.transform)
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
- const options = { aggregate: true, cycles: true };
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 transformModel(model, { ...options, ...overrides });
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 = ENTITY_DECL.exec(code);
157
- const name = match ? extractDeclaredName(match[1]) : null;
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 = ENTITY_DECL.exec(code);
208
- const name = match ? extractDeclaredName(match[1]) : null;
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
- * Extract a declaration name from an entity line's tail, mirroring the parser:
233
- * accepts a bare token (`class A`) or a quoted name (`class "User Repo"`), and
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;