@yalehwang/plantuml-js 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/core.d.ts +106 -13
- package/dist/core.js +92 -52
- package/dist/foundation/labels.d.ts +44 -0
- package/dist/foundation/labels.js +231 -0
- 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 +74 -0
- package/dist/layout/compound.js +1204 -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 +80 -33
- package/dist/layout/elk.js +229 -320
- 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/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/metrics/layout-metrics.d.ts +183 -0
- package/dist/metrics/layout-metrics.js +766 -0
- package/dist/parse/parser.d.ts +43 -0
- package/dist/{parser.js → parse/parser.js} +111 -5
- package/dist/parse/serialize.d.ts +32 -0
- package/dist/parse/serialize.js +218 -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/{transform.d.ts → transform/transform.d.ts} +55 -1
- package/dist/{transform.js → transform/transform.js} +277 -29
- package/package.json +4 -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/parser.d.ts +0 -17
- /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 → layout/render-dot.d.ts} +0 -0
- /package/dist/{render-dot.js → layout/render-dot.js} +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-written recursive-descent parser for the PlantUML class-diagram subset.
|
|
3
|
+
*
|
|
4
|
+
* M2 scope adds members (visibility `-`/`+`/`#`/`~`, fields, method
|
|
5
|
+
* signatures), relations (inheritance / implementation / dependency /
|
|
6
|
+
* association / aggregation / composition with cardinality and labels),
|
|
7
|
+
* generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
|
|
8
|
+
* aliases, and tolerant skipping of skinparam/hide/note directives.
|
|
9
|
+
*/
|
|
10
|
+
import { PlantUmlModel } from '../foundation/model.js';
|
|
11
|
+
export declare const ENTITY_LINE: RegExp;
|
|
12
|
+
export declare const ENTITY_KEYWORD: RegExp;
|
|
13
|
+
/**
|
|
14
|
+
* Parse a PlantUML source string into a PlantUmlModel.
|
|
15
|
+
*
|
|
16
|
+
* The parser is total: unrecognised lines (skinparam, hide, note, title,
|
|
17
|
+
* legend, …) are tolerated and skipped rather than failing the parse.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parsePlantUml(source: string): PlantUmlModel;
|
|
20
|
+
/** An undeclared relation endpoint that the parser resolved on the author's behalf. */
|
|
21
|
+
export interface EndpointDiagnostic {
|
|
22
|
+
/** 1-based source line of the relation. */
|
|
23
|
+
line: number;
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse and also report how relation endpoints were resolved: endpoints naming
|
|
28
|
+
* a package are re-attached to a representative entity inside it, and endpoints
|
|
29
|
+
* naming nothing declared become implicit (empty) classes, as PlantUML does.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseWithDiagnostics(source: string): {
|
|
32
|
+
model: PlantUmlModel;
|
|
33
|
+
diagnostics: EndpointDiagnostic[];
|
|
34
|
+
};
|
|
35
|
+
/** Strip PlantUML `'` line comments (a leading `'` or a trailing inline `'`). */
|
|
36
|
+
export declare function stripComment(line: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Extract a declaration name from an entity line's tail, mirroring the parser:
|
|
39
|
+
* accepts a bare token (`class A`) or a quoted name (`class "User Repo"`), and
|
|
40
|
+
* tolerates a trailing `as <alias>`. Returns null when the name is missing or
|
|
41
|
+
* the quoted name is unterminated.
|
|
42
|
+
*/
|
|
43
|
+
export declare function extractDeclaredName(rest: string): string | null;
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
|
|
8
8
|
* aliases, and tolerant skipping of skinparam/hide/note directives.
|
|
9
9
|
*/
|
|
10
|
-
import { emptyModel, } from '
|
|
11
|
-
const ENTITY_LINE = /^(abstract\s+)?(class|interface|enum)\s+(.+)$/i;
|
|
10
|
+
import { emptyModel, } from '../foundation/model.js';
|
|
11
|
+
export const ENTITY_LINE = /^(abstract\s+)?(class|interface|enum)\s+(.+)$/i;
|
|
12
|
+
export const ENTITY_KEYWORD = /^(?:abstract\s+)?(?:class|interface|enum)\b/i;
|
|
12
13
|
const ARROWS = {
|
|
13
14
|
'--|>': { type: 'inheritance' },
|
|
14
15
|
'<|--': { type: 'inheritance', swap: true },
|
|
@@ -31,6 +32,91 @@ const ARROWS = {
|
|
|
31
32
|
* legend, …) are tolerated and skipped rather than failing the parse.
|
|
32
33
|
*/
|
|
33
34
|
export function parsePlantUml(source) {
|
|
35
|
+
return parseWithDiagnostics(source).model;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse and also report how relation endpoints were resolved: endpoints naming
|
|
39
|
+
* a package are re-attached to a representative entity inside it, and endpoints
|
|
40
|
+
* naming nothing declared become implicit (empty) classes, as PlantUML does.
|
|
41
|
+
*/
|
|
42
|
+
export function parseWithDiagnostics(source) {
|
|
43
|
+
const relationLines = [];
|
|
44
|
+
const model = parseRaw(source, relationLines);
|
|
45
|
+
const diagnostics = resolveEndpoints(model, relationLines);
|
|
46
|
+
return { model, diagnostics };
|
|
47
|
+
}
|
|
48
|
+
function resolveEndpoints(model, relationLines) {
|
|
49
|
+
const diagnostics = [];
|
|
50
|
+
const entityIds = new Set(model.entities.map((e) => e.id));
|
|
51
|
+
const packageIds = new Set(model.packages.map((p) => p.id));
|
|
52
|
+
const parentOf = new Map(model.packages.map((p) => [p.id, p.parentId]));
|
|
53
|
+
const inPackage = (entityPkg, pkg) => {
|
|
54
|
+
for (let cur = entityPkg; cur !== undefined; cur = parentOf.get(cur)) {
|
|
55
|
+
if (cur === pkg)
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
const representative = (pkg) => model.entities.find((e) => inPackage(e.packageId, pkg))?.id;
|
|
61
|
+
// An endpoint may name a package by its id or by the identifier form of its
|
|
62
|
+
// display name (`CLI_Layer` for `"CLI Layer (命令层)"`), as authors write it.
|
|
63
|
+
const packageByKey = new Map();
|
|
64
|
+
for (const p of model.packages) {
|
|
65
|
+
for (const key of [p.id, packageKey(p.name)]) {
|
|
66
|
+
if (!packageByKey.has(key))
|
|
67
|
+
packageByKey.set(key, p.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const asPackage = (id) => packageIds.has(id) ? id : packageByKey.get(id);
|
|
71
|
+
const resolve = (id, line, pkgOut) => {
|
|
72
|
+
if (entityIds.has(id))
|
|
73
|
+
return id;
|
|
74
|
+
const pkg = asPackage(id);
|
|
75
|
+
if (pkg !== undefined) {
|
|
76
|
+
const rep = representative(pkg);
|
|
77
|
+
if (rep !== undefined) {
|
|
78
|
+
diagnostics.push({
|
|
79
|
+
line,
|
|
80
|
+
message: `relation endpoint "${id}" is a package; edge attached to "${rep}" inside it`,
|
|
81
|
+
});
|
|
82
|
+
pkgOut(pkg);
|
|
83
|
+
return rep;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
model.entities.push({
|
|
87
|
+
id,
|
|
88
|
+
name: id,
|
|
89
|
+
generics: [],
|
|
90
|
+
kind: 'class',
|
|
91
|
+
members: [],
|
|
92
|
+
external: true,
|
|
93
|
+
});
|
|
94
|
+
entityIds.add(id);
|
|
95
|
+
diagnostics.push({
|
|
96
|
+
line,
|
|
97
|
+
message: `relation endpoint "${id}" is not declared; implicit class created`,
|
|
98
|
+
});
|
|
99
|
+
return id;
|
|
100
|
+
};
|
|
101
|
+
model.relations.forEach((r, i) => {
|
|
102
|
+
const line = relationLines[i] ?? 0;
|
|
103
|
+
r.source = resolve(r.source, line, (p) => {
|
|
104
|
+
r.sourcePackageId = p;
|
|
105
|
+
});
|
|
106
|
+
r.target = resolve(r.target, line, (p) => {
|
|
107
|
+
r.targetPackageId = p;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
return diagnostics;
|
|
111
|
+
}
|
|
112
|
+
/** Identifier form of a package display name: parenthetical dropped, non-word runs → `_`. */
|
|
113
|
+
function packageKey(name) {
|
|
114
|
+
return name
|
|
115
|
+
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
116
|
+
.trim()
|
|
117
|
+
.replace(/[^\p{L}\p{N}]+/gu, '_');
|
|
118
|
+
}
|
|
119
|
+
function parseRaw(source, relationLines) {
|
|
34
120
|
const model = emptyModel();
|
|
35
121
|
const lines = source.split(/\r?\n/);
|
|
36
122
|
let inDiagram = false;
|
|
@@ -40,8 +126,8 @@ export function parsePlantUml(source) {
|
|
|
40
126
|
stack.push(frame);
|
|
41
127
|
lastDeclaration = null;
|
|
42
128
|
};
|
|
43
|
-
for (
|
|
44
|
-
const line =
|
|
129
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
130
|
+
const line = lines[lineIdx].trim();
|
|
45
131
|
if (line === '')
|
|
46
132
|
continue;
|
|
47
133
|
if (/^@startuml\b/i.test(line)) {
|
|
@@ -116,6 +202,7 @@ export function parsePlantUml(source) {
|
|
|
116
202
|
const relation = parseRelationLine(code);
|
|
117
203
|
if (relation) {
|
|
118
204
|
model.relations.push(relation);
|
|
205
|
+
relationLines.push(lineIdx + 1);
|
|
119
206
|
lastDeclaration = null;
|
|
120
207
|
continue;
|
|
121
208
|
}
|
|
@@ -366,9 +453,28 @@ function stripQuotes(token) {
|
|
|
366
453
|
return token;
|
|
367
454
|
}
|
|
368
455
|
/** Strip PlantUML `'` line comments (a leading `'` or a trailing inline `'`). */
|
|
369
|
-
function stripComment(line) {
|
|
456
|
+
export function stripComment(line) {
|
|
370
457
|
const idx = line.indexOf("'");
|
|
371
458
|
if (idx === -1)
|
|
372
459
|
return line.trim();
|
|
373
460
|
return line.slice(0, idx).trim();
|
|
374
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Extract a declaration name from an entity line's tail, mirroring the parser:
|
|
464
|
+
* accepts a bare token (`class A`) or a quoted name (`class "User Repo"`), and
|
|
465
|
+
* tolerates a trailing `as <alias>`. Returns null when the name is missing or
|
|
466
|
+
* the quoted name is unterminated.
|
|
467
|
+
*/
|
|
468
|
+
export function extractDeclaredName(rest) {
|
|
469
|
+
const asIndex = rest.search(/\s+as\s+\S+\s*$/i);
|
|
470
|
+
const core = asIndex === -1 ? rest.trim() : rest.slice(0, asIndex).trim();
|
|
471
|
+
if (core === '')
|
|
472
|
+
return null;
|
|
473
|
+
if (core.startsWith('"')) {
|
|
474
|
+
const close = core.indexOf('"', 1);
|
|
475
|
+
if (close === -1)
|
|
476
|
+
return null;
|
|
477
|
+
return core.slice(1, close);
|
|
478
|
+
}
|
|
479
|
+
return core.split(/\s+/)[0] || null;
|
|
480
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlantUmlModel → PlantUML class-diagram text.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of [[parsePlantUml]]: `toPlantUml(renderDetailed(...).model)`
|
|
5
|
+
* reproduces the diagram that was actually drawn (after view/transform/detail)
|
|
6
|
+
* as source a user can copy and paste. Pure and synchronous — the model is the
|
|
7
|
+
* only input, so the caller composes the scope by picking the model
|
|
8
|
+
* (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
|
|
9
|
+
* function never chooses a range on its own.
|
|
10
|
+
*
|
|
11
|
+
* Shapes that only exist after parsing (stub/external/usageCount, aggregated
|
|
12
|
+
* edge weights, cycle markers) are rendering semantics with no PlantUML
|
|
13
|
+
* spelling; they serialize as their plain-text equivalent (stub → class,
|
|
14
|
+
* weighted edge → one edge). The parts that do have a spelling — packages
|
|
15
|
+
* (nested), entity kind/generics/alias, member rows, relation arrow/label/
|
|
16
|
+
* cardinality — round-trip back through [[parsePlantUml]] unchanged.
|
|
17
|
+
*
|
|
18
|
+
* The displayed name is always emitted quoted (`class "<<function>> halt"
|
|
19
|
+
* as n0`), so names carrying a stereotype prefix or `/` path separators survive
|
|
20
|
+
* the round trip as authored.
|
|
21
|
+
*/
|
|
22
|
+
import type { PlantUmlModel } from '../foundation/model.js';
|
|
23
|
+
/**
|
|
24
|
+
* Serialize a model to `@startuml … @enduml` class-diagram source.
|
|
25
|
+
*
|
|
26
|
+
* Emitted layout: nested `package` blocks first (each holding the entities that
|
|
27
|
+
* carry its `packageId`), then package-less entities, then every relation.
|
|
28
|
+
* Entities always use the quoted form with an explicit alias, which is the only
|
|
29
|
+
* form that carries a display name containing whitespace, `/`, or a
|
|
30
|
+
* `<<stereotype>>` prefix.
|
|
31
|
+
*/
|
|
32
|
+
export declare function toPlantUml(model: PlantUmlModel): string;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlantUmlModel → PlantUML class-diagram text.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of [[parsePlantUml]]: `toPlantUml(renderDetailed(...).model)`
|
|
5
|
+
* reproduces the diagram that was actually drawn (after view/transform/detail)
|
|
6
|
+
* as source a user can copy and paste. Pure and synchronous — the model is the
|
|
7
|
+
* only input, so the caller composes the scope by picking the model
|
|
8
|
+
* (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
|
|
9
|
+
* function never chooses a range on its own.
|
|
10
|
+
*
|
|
11
|
+
* Shapes that only exist after parsing (stub/external/usageCount, aggregated
|
|
12
|
+
* edge weights, cycle markers) are rendering semantics with no PlantUML
|
|
13
|
+
* spelling; they serialize as their plain-text equivalent (stub → class,
|
|
14
|
+
* weighted edge → one edge). The parts that do have a spelling — packages
|
|
15
|
+
* (nested), entity kind/generics/alias, member rows, relation arrow/label/
|
|
16
|
+
* cardinality — round-trip back through [[parsePlantUml]] unchanged.
|
|
17
|
+
*
|
|
18
|
+
* The displayed name is always emitted quoted (`class "<<function>> halt"
|
|
19
|
+
* as n0`), so names carrying a stereotype prefix or `/` path separators survive
|
|
20
|
+
* the round trip as authored.
|
|
21
|
+
*/
|
|
22
|
+
/** A bare PlantUML identifier (what `as <alias>` accepts). */
|
|
23
|
+
const PLANTUML_ID = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
24
|
+
const INDENT = ' ';
|
|
25
|
+
const VISIBILITY_SYMBOL = {
|
|
26
|
+
public: '+',
|
|
27
|
+
private: '-',
|
|
28
|
+
protected: '#',
|
|
29
|
+
'package-private': '~',
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* One arrow per relation type, written in canonical source→target direction.
|
|
33
|
+
* The parser's own arrow table also accepts the swapped mirror forms
|
|
34
|
+
* (`<|--`, `--o`, …); emitting the canonical form is what makes the round trip
|
|
35
|
+
* stable.
|
|
36
|
+
*/
|
|
37
|
+
const RELATION_ARROW = {
|
|
38
|
+
inheritance: '--|>',
|
|
39
|
+
implementation: '..|>',
|
|
40
|
+
dependency: '..>',
|
|
41
|
+
association: '-->',
|
|
42
|
+
aggregation: 'o--',
|
|
43
|
+
composition: '*--',
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Serialize a model to `@startuml … @enduml` class-diagram source.
|
|
47
|
+
*
|
|
48
|
+
* Emitted layout: nested `package` blocks first (each holding the entities that
|
|
49
|
+
* carry its `packageId`), then package-less entities, then every relation.
|
|
50
|
+
* Entities always use the quoted form with an explicit alias, which is the only
|
|
51
|
+
* form that carries a display name containing whitespace, `/`, or a
|
|
52
|
+
* `<<stereotype>>` prefix.
|
|
53
|
+
*/
|
|
54
|
+
export function toPlantUml(model) {
|
|
55
|
+
const entities = new AliasBook('entity');
|
|
56
|
+
const packages = new AliasBook('package');
|
|
57
|
+
const lines = ['@startuml'];
|
|
58
|
+
const nodes = packageNodes(model);
|
|
59
|
+
const emitted = new Set();
|
|
60
|
+
for (const node of nodes.roots)
|
|
61
|
+
emitPackage(node, 0, lines, entities, packages, emitted);
|
|
62
|
+
// Defensive: a parentId cycle leaves nodes unreachable from any root; emit
|
|
63
|
+
// each remaining package as a root rather than silently dropping it.
|
|
64
|
+
for (const node of nodes.all) {
|
|
65
|
+
if (!emitted.has(node.pkg.id))
|
|
66
|
+
emitPackage(node, 0, lines, entities, packages, emitted);
|
|
67
|
+
}
|
|
68
|
+
for (const entity of nodes.topLevel) {
|
|
69
|
+
lines.push(...entityLines(entity, 0, entities));
|
|
70
|
+
}
|
|
71
|
+
for (const relation of model.relations) {
|
|
72
|
+
lines.push(relationLine(relation, entities));
|
|
73
|
+
}
|
|
74
|
+
lines.push('@enduml');
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Index packages into a tree, attaching each entity to the innermost package it
|
|
79
|
+
* names. An entity whose `packageId` names no declared package is emitted at
|
|
80
|
+
* the top level instead of being dropped.
|
|
81
|
+
*/
|
|
82
|
+
function packageNodes(model) {
|
|
83
|
+
const byId = new Map();
|
|
84
|
+
const all = [];
|
|
85
|
+
for (const pkg of model.packages) {
|
|
86
|
+
// A duplicated id keeps its first package; the parser never produces one.
|
|
87
|
+
if (byId.has(pkg.id))
|
|
88
|
+
continue;
|
|
89
|
+
const node = { pkg, children: [], entities: [] };
|
|
90
|
+
byId.set(pkg.id, node);
|
|
91
|
+
all.push(node);
|
|
92
|
+
}
|
|
93
|
+
const roots = [];
|
|
94
|
+
for (const node of all) {
|
|
95
|
+
const parentId = node.pkg.parentId;
|
|
96
|
+
const parent = parentId === undefined ? undefined : byId.get(parentId);
|
|
97
|
+
if (parent !== undefined && parent !== node)
|
|
98
|
+
parent.children.push(node);
|
|
99
|
+
else
|
|
100
|
+
roots.push(node);
|
|
101
|
+
}
|
|
102
|
+
const topLevel = [];
|
|
103
|
+
for (const entity of model.entities) {
|
|
104
|
+
const owner = entity.packageId === undefined ? undefined : byId.get(entity.packageId);
|
|
105
|
+
if (owner !== undefined)
|
|
106
|
+
owner.entities.push(entity);
|
|
107
|
+
else
|
|
108
|
+
topLevel.push(entity);
|
|
109
|
+
}
|
|
110
|
+
return { roots, all, topLevel };
|
|
111
|
+
}
|
|
112
|
+
function emitPackage(node, depth, lines, entities, packages, emitted) {
|
|
113
|
+
if (emitted.has(node.pkg.id))
|
|
114
|
+
return;
|
|
115
|
+
emitted.add(node.pkg.id);
|
|
116
|
+
const indent = INDENT.repeat(depth);
|
|
117
|
+
const alias = packages.alias(node.pkg.id, node.pkg.name);
|
|
118
|
+
lines.push(`${indent}package ${quoteName(node.pkg.name)} as ${alias} {`);
|
|
119
|
+
for (const entity of node.entities) {
|
|
120
|
+
lines.push(...entityLines(entity, depth + 1, entities));
|
|
121
|
+
}
|
|
122
|
+
for (const child of node.children) {
|
|
123
|
+
emitPackage(child, depth + 1, lines, entities, packages, emitted);
|
|
124
|
+
}
|
|
125
|
+
lines.push(`${indent}}`);
|
|
126
|
+
}
|
|
127
|
+
function entityKeyword(entity) {
|
|
128
|
+
return entity.kind === 'abstract' ? 'abstract class' : entity.kind;
|
|
129
|
+
}
|
|
130
|
+
function entityLines(entity, depth, aliases) {
|
|
131
|
+
const indent = INDENT.repeat(depth);
|
|
132
|
+
const alias = aliases.alias(entity.id, entity.name);
|
|
133
|
+
const head = `${entityKeyword(entity)} ${quoteName(entity.name)}${genericSuffix(entity)} as ${alias}`;
|
|
134
|
+
if (entity.members.length === 0)
|
|
135
|
+
return [`${indent}${head}`];
|
|
136
|
+
const lines = [`${indent}${head} {`];
|
|
137
|
+
for (const member of entity.members) {
|
|
138
|
+
lines.push(`${indent}${INDENT}${memberLine(member)}`);
|
|
139
|
+
}
|
|
140
|
+
lines.push(`${indent}}`);
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
function genericSuffix(entity) {
|
|
144
|
+
const generics = entity.generics ?? [];
|
|
145
|
+
return generics.length === 0 ? '' : `~${generics.join(',')}~`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One member row. The authored `raw` text is authoritative (it carries the
|
|
149
|
+
* visibility symbol and the exact signature), except for synthesized rows —
|
|
150
|
+
* the detail pipeline's `… +N more (M members)` truncation marker has no
|
|
151
|
+
* visibility symbol, so it gets one prefixed to stay parseable as a member
|
|
152
|
+
* row while keeping the marker text verbatim.
|
|
153
|
+
*/
|
|
154
|
+
function memberLine(member) {
|
|
155
|
+
const raw = (member.raw ?? '').trim();
|
|
156
|
+
const symbol = VISIBILITY_SYMBOL[member.visibility] ?? '+';
|
|
157
|
+
if (raw === '')
|
|
158
|
+
return symbol;
|
|
159
|
+
if (/^[+\-#~]/.test(raw))
|
|
160
|
+
return raw;
|
|
161
|
+
return `${symbol} ${raw}`;
|
|
162
|
+
}
|
|
163
|
+
function relationLine(relation, aliases) {
|
|
164
|
+
const source = aliases.alias(relation.source, relation.source);
|
|
165
|
+
const target = aliases.alias(relation.target, relation.target);
|
|
166
|
+
const parts = [source];
|
|
167
|
+
if (relation.sourceCardinality !== undefined)
|
|
168
|
+
parts.push(quoteName(relation.sourceCardinality));
|
|
169
|
+
parts.push(RELATION_ARROW[relation.type] ?? '-->');
|
|
170
|
+
if (relation.targetCardinality !== undefined)
|
|
171
|
+
parts.push(quoteName(relation.targetCardinality));
|
|
172
|
+
parts.push(target);
|
|
173
|
+
let line = parts.join(' ');
|
|
174
|
+
if (relation.label !== undefined && relation.label !== '') {
|
|
175
|
+
line += ` : ${relation.label.replace(/[\r\n]+/g, ' ')}`;
|
|
176
|
+
}
|
|
177
|
+
return line;
|
|
178
|
+
}
|
|
179
|
+
/** Quote a display name; a `"` inside a PlantUML name is written as `'`. */
|
|
180
|
+
function quoteName(name) {
|
|
181
|
+
const safe = name.replace(/[\r\n\t]+/g, ' ').replace(/"/g, "'");
|
|
182
|
+
return `"${safe}"`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Deterministic, collision-free alias assignment, in emission order. A
|
|
186
|
+
* plant-legal id is kept verbatim (so a parsed model round-trips its ids); an
|
|
187
|
+
* id PlantUML cannot spell (view-internal ids such as `ov::src`, `stub::x`)
|
|
188
|
+
* falls back to an identifier derived from the display name.
|
|
189
|
+
*/
|
|
190
|
+
class AliasBook {
|
|
191
|
+
used = new Set();
|
|
192
|
+
byId = new Map();
|
|
193
|
+
fallback;
|
|
194
|
+
constructor(fallback) {
|
|
195
|
+
this.fallback = fallback;
|
|
196
|
+
}
|
|
197
|
+
alias(id, name) {
|
|
198
|
+
const existing = this.byId.get(id);
|
|
199
|
+
if (existing !== undefined)
|
|
200
|
+
return existing;
|
|
201
|
+
const base = PLANTUML_ID.test(id) ? id : identifierFrom(name, this.fallback);
|
|
202
|
+
let alias = base;
|
|
203
|
+
let suffix = 2;
|
|
204
|
+
while (this.used.has(alias))
|
|
205
|
+
alias = `${base}_${suffix++}`;
|
|
206
|
+
this.used.add(alias);
|
|
207
|
+
this.byId.set(id, alias);
|
|
208
|
+
return alias;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Derive a bare identifier from a display name (`Src Layer` → `Src_Layer`). */
|
|
212
|
+
function identifierFrom(name, fallback) {
|
|
213
|
+
const cleaned = name
|
|
214
|
+
.replace(/[^A-Za-z0-9_]+/g, '_')
|
|
215
|
+
.replace(/^_+|_+$/g, '');
|
|
216
|
+
const base = cleaned === '' ? fallback : cleaned;
|
|
217
|
+
return /^[A-Za-z_]/.test(base) ? base : `_${base}`;
|
|
218
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legend — a small key explaining the visual encodings of a rendered diagram:
|
|
3
|
+
* feedback (back) edges, hub badges, `×N` weights and the dashed/solid node
|
|
4
|
+
* frames. Drawn below the diagram, so it never covers layout geometry.
|
|
5
|
+
*/
|
|
6
|
+
import { Theme } from './theme.js';
|
|
7
|
+
/** Entity count above which the legend is drawn by default. */
|
|
8
|
+
export declare const LEGEND_DEFAULT_NODE_THRESHOLD = 30;
|
|
9
|
+
export interface Legend {
|
|
10
|
+
/** `<g class="uml-legend">` markup positioned at (x, y). */
|
|
11
|
+
svg: string;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function renderLegend(x: number, y: number, theme: Theme): Legend;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legend — a small key explaining the visual encodings of a rendered diagram:
|
|
3
|
+
* feedback (back) edges, hub badges, `×N` weights and the dashed/solid node
|
|
4
|
+
* frames. Drawn below the diagram, so it never covers layout geometry.
|
|
5
|
+
*/
|
|
6
|
+
const ROW_HEIGHT = 20;
|
|
7
|
+
const PAD = 10;
|
|
8
|
+
const SWATCH_WIDTH = 34;
|
|
9
|
+
const FONT_SIZE = 11;
|
|
10
|
+
/** Entity count above which the legend is drawn by default. */
|
|
11
|
+
export const LEGEND_DEFAULT_NODE_THRESHOLD = 30;
|
|
12
|
+
const ITEMS = [
|
|
13
|
+
'feedback edge (dash-dot; closes a dependency cycle)',
|
|
14
|
+
'hub badge: number of dependents (edges hidden)',
|
|
15
|
+
'×N: number of merged relations',
|
|
16
|
+
'dashed frame: grouped / external package',
|
|
17
|
+
'solid frame: single module',
|
|
18
|
+
];
|
|
19
|
+
export function renderLegend(x, y, theme) {
|
|
20
|
+
const { color } = theme;
|
|
21
|
+
const width = PAD * 2 + SWATCH_WIDTH + 8 + 300;
|
|
22
|
+
const height = PAD * 2 + ROW_HEIGHT * ITEMS.length;
|
|
23
|
+
const rowY = (i) => y + PAD + ROW_HEIGHT * i + ROW_HEIGHT / 2;
|
|
24
|
+
const sx = x + PAD;
|
|
25
|
+
const tx = sx + SWATCH_WIDTH + 8;
|
|
26
|
+
const swatches = [
|
|
27
|
+
`<line x1="${sx}" y1="${rowY(0)}" x2="${sx + SWATCH_WIDTH}" y2="${rowY(0)}" stroke="${color.warn}" stroke-width="${theme.stroke.edge}" stroke-dasharray="${theme.dash.feedback}"/>`,
|
|
28
|
+
`<circle cx="${sx + SWATCH_WIDTH / 2}" cy="${rowY(1)}" r="8" fill="${color.accent}"/>` +
|
|
29
|
+
`<text x="${sx + SWATCH_WIDTH / 2}" y="${rowY(1) + 3.5}" text-anchor="middle" font-family="${theme.font.family}" font-size="10" fill="${color.accentText}">N</text>`,
|
|
30
|
+
`<line x1="${sx}" y1="${rowY(2)}" x2="${sx + SWATCH_WIDTH}" y2="${rowY(2)}" stroke="${color.edge}" stroke-width="2.5" stroke-opacity="${theme.stroke.opacity.strong}"/>`,
|
|
31
|
+
`<rect x="${sx}" y="${rowY(3) - 7}" width="${SWATCH_WIDTH}" height="14" rx="3" fill="${color.stubFill}" stroke="${color.strokeStub}" stroke-width="${theme.stroke.node}" stroke-dasharray="${theme.dash.stub}"/>`,
|
|
32
|
+
`<rect x="${sx}" y="${rowY(4) - 7}" width="${SWATCH_WIDTH}" height="14" rx="3" fill="${color.nodeFill}" stroke="${color.stroke}" stroke-width="${theme.stroke.node}"/>`,
|
|
33
|
+
];
|
|
34
|
+
const texts = ITEMS.map((item, i) => `<text x="${tx}" y="${rowY(i) + 4}" text-anchor="start" font-family="${theme.font.family}"` +
|
|
35
|
+
` font-size="${FONT_SIZE}" fill="${color.textMember}">${item.replace(/&/g, '&').replace(/</g, '<')}</text>`);
|
|
36
|
+
const svg = `<g class="uml-legend">` +
|
|
37
|
+
`<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" fill="${color.canvas}" stroke="${color.legendStroke}" stroke-width="${theme.stroke.node}"/>` +
|
|
38
|
+
swatches.join('') +
|
|
39
|
+
texts.join('') +
|
|
40
|
+
`</g>`;
|
|
41
|
+
return { svg, width, height };
|
|
42
|
+
}
|
package/dist/render/svg.d.ts
CHANGED
|
@@ -3,7 +3,64 @@
|
|
|
3
3
|
* The renderer consumes only the LayoutResult (positions/sizes) plus the
|
|
4
4
|
* model's display fields; it never reaches back into the parser.
|
|
5
5
|
*/
|
|
6
|
-
import { PlantUmlModel } from '../model.js';
|
|
7
|
-
import { LayoutResult } from '../layout.js';
|
|
6
|
+
import { PlantUmlModel } from '../foundation/model.js';
|
|
7
|
+
import { LayoutResult, Point } from '../foundation/layout.js';
|
|
8
|
+
import { ThemeOverrides } from './theme.js';
|
|
8
9
|
export declare function escapeXml(text: string): string;
|
|
9
|
-
export
|
|
10
|
+
export interface RenderSvgOptions {
|
|
11
|
+
/** Draw a legend of the visual encodings. Default: on for models with more than 30 entities. */
|
|
12
|
+
legend?: boolean;
|
|
13
|
+
/** Theme token overrides over `defaultTheme`. */
|
|
14
|
+
theme?: ThemeOverrides;
|
|
15
|
+
/** Edges hidden by transitive reduction; > 0 forces the legend and is reported there and in `data-hidden-transitive`. */
|
|
16
|
+
hiddenTransitive?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Per-frame values handed to {@link RenderEngine.render}. Only values computed
|
|
20
|
+
* for the frame being rendered belong here; configuration that outlives a
|
|
21
|
+
* frame (theme, legend override) is constructor state of the engine.
|
|
22
|
+
*/
|
|
23
|
+
export interface RenderFrameOptions {
|
|
24
|
+
/** Edges hidden by transitive reduction in this frame; > 0 forces the legend and is reported there and in `data-hidden-transitive`. */
|
|
25
|
+
hiddenTransitive?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The rendering counterpart of `LayoutEngine`: draw an already laid-out model.
|
|
29
|
+
* Synchronous because SVG assembly is synchronous, unlike layout. Inject one
|
|
30
|
+
* through `RendererOptions.renderer` to swap the rendering backend.
|
|
31
|
+
*/
|
|
32
|
+
export interface RenderEngine {
|
|
33
|
+
render(model: PlantUmlModel, layout: LayoutResult, frame?: RenderFrameOptions): string;
|
|
34
|
+
}
|
|
35
|
+
/** Configuration that outlives a frame — constructor state of {@link SvgRenderer}. */
|
|
36
|
+
export interface SvgRendererOptions {
|
|
37
|
+
/** Draw a legend of the visual encodings. Default: on for models with more than 30 entities. */
|
|
38
|
+
legend?: boolean;
|
|
39
|
+
/** Theme token overrides over `defaultTheme`. */
|
|
40
|
+
theme?: ThemeOverrides;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The built-in {@link RenderEngine}: the SVG backend. Theme and legend
|
|
44
|
+
* configuration are fixed at construction; the per-frame `hiddenTransitive`
|
|
45
|
+
* count is passed to [[SvgRenderer.render]].
|
|
46
|
+
*/
|
|
47
|
+
export declare class SvgRenderer implements RenderEngine {
|
|
48
|
+
private readonly legend;
|
|
49
|
+
private readonly theme;
|
|
50
|
+
constructor(options?: SvgRendererOptions);
|
|
51
|
+
render(model: PlantUmlModel, layout: LayoutResult, frame?: RenderFrameOptions): string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Convenience wrapper over {@link SvgRenderer}, kept for callers that build the
|
|
55
|
+
* SVG directly from a model + layout (the renderer class is preferred when
|
|
56
|
+
* injecting into `RendererOptions.renderer`).
|
|
57
|
+
*/
|
|
58
|
+
export declare function renderSvg(model: PlantUmlModel, layout: LayoutResult, options?: RenderSvgOptions): string;
|
|
59
|
+
/** Edges sharing a source and exit direction beyond this count are drawn as one bundle. */
|
|
60
|
+
export declare const EDGE_BUNDLE_MIN = 5;
|
|
61
|
+
/**
|
|
62
|
+
* Polyline as an SVG path whose interior corners are rounded with quadratic
|
|
63
|
+
* curves of radius ≤ `radius` (clamped to half of each adjoining segment). The
|
|
64
|
+
* routing — the vertices — is unchanged.
|
|
65
|
+
*/
|
|
66
|
+
export declare function roundedPath(points: Point[], radius: number): string;
|