bc-atlas 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ export function normalizeIdentifier(value) {
2
+ return String(value ?? "")
3
+ .trim()
4
+ .replace(/"((?:""|[^"])*)"/gu, (_match, content) => content.replaceAll('""', '"'))
5
+ .toLocaleLowerCase("en-US");
6
+ }
7
+
8
+ function symbolKey(object) {
9
+ const app = object.app?.id ?? object.app?.name ?? "workspace";
10
+ const identity = object.id || normalizeIdentifier(object.name);
11
+ return [app, object.namespace, object.type, identity].join("::");
12
+ }
13
+
14
+ function lookupKeys(object) {
15
+ const values = [
16
+ normalizeIdentifier(object.name),
17
+ normalizeIdentifier(`${object.namespace}.${object.name}`)
18
+ ];
19
+ if (object.id) values.push(String(object.id));
20
+ return values.flatMap((value) => [
21
+ `${object.type}:${value}`,
22
+ `*:${value}`
23
+ ]);
24
+ }
25
+
26
+ export function resolveModel(model) {
27
+ const diagnostics = [...(model.diagnostics ?? [])];
28
+ const index = new Map();
29
+
30
+ for (const object of model.objects) {
31
+ object.key = symbolKey(object);
32
+ for (const key of lookupKeys(object)) {
33
+ const candidates = index.get(key) ?? [];
34
+ candidates.push(object);
35
+ index.set(key, candidates);
36
+ }
37
+ }
38
+
39
+ function resolve(source, relation) {
40
+ const target = normalizeIdentifier(relation.target);
41
+ let candidates =
42
+ index.get(`${relation.targetType ?? "*"}:${target}`) ??
43
+ index.get(`*:${target}`) ??
44
+ [];
45
+ if (candidates.length > 1) {
46
+ const sameNamespace = candidates.filter(
47
+ (candidate) => candidate.namespace === source.namespace
48
+ );
49
+ if (sameNamespace.length) candidates = sameNamespace;
50
+ }
51
+ if (candidates.length > 1 && source.app) {
52
+ const sameApp = candidates.filter(
53
+ (candidate) => candidate.app?.id === source.app.id
54
+ );
55
+ if (sameApp.length) candidates = sameApp;
56
+ }
57
+ if (candidates.length > 1 && source.app?.dependencies?.length) {
58
+ const dependencyIds = new Set(
59
+ source.app.dependencies.flatMap((dependency) => [dependency.id, dependency.appId])
60
+ );
61
+ const dependencyCandidates = candidates.filter(
62
+ (candidate) => candidate.app?.id && dependencyIds.has(candidate.app.id)
63
+ );
64
+ if (dependencyCandidates.length) candidates = dependencyCandidates;
65
+ }
66
+ if (candidates.length === 1) return candidates[0];
67
+ if (candidates.length > 1) {
68
+ diagnostics.push({
69
+ severity: "warning",
70
+ code: "ambiguous-reference",
71
+ message: `${source.type} "${source.name}" references ambiguous ${relation.targetType ?? "object"} "${relation.target}"`,
72
+ file: relation.location?.file ?? source.file,
73
+ line: relation.location?.line ?? source.location?.line,
74
+ candidates: candidates.map(({ key }) => key)
75
+ });
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ const edges = [];
81
+ for (const object of model.objects) {
82
+ for (const relation of object.relations) {
83
+ const target = resolve(object, relation);
84
+ if (!target) {
85
+ diagnostics.push({
86
+ severity: "info",
87
+ code: "unresolved-reference",
88
+ message: `${object.type} "${object.name}" references external or unresolved ${relation.targetType ?? "object"} "${relation.target}"`,
89
+ file: relation.location?.file ?? object.file,
90
+ line: relation.location?.line ?? object.location?.line
91
+ });
92
+ }
93
+ edges.push({
94
+ id: `e${edges.length}`,
95
+ from: object.key,
96
+ to: target?.key,
97
+ unresolved: target ? undefined : {
98
+ name: relation.target,
99
+ type: relation.targetType ?? "object"
100
+ },
101
+ kind: relation.kind,
102
+ access: relation.access,
103
+ operation: relation.operation,
104
+ property: relation.property,
105
+ member: relation.member,
106
+ contract: relation.contract,
107
+ confidence: target ? "resolved" : "syntactic",
108
+ location: relation.location
109
+ });
110
+ }
111
+ }
112
+
113
+ return { ...model, edges, diagnostics };
114
+ }
package/src/svg.js ADDED
@@ -0,0 +1,42 @@
1
+ import { D2 } from "@terrastruct/d2";
2
+
3
+ let renderer;
4
+
5
+ function getRenderer() {
6
+ renderer ??= new D2();
7
+ return renderer;
8
+ }
9
+
10
+ function optionalNumber(value, name) {
11
+ if (value === undefined) return undefined;
12
+ const number = Number(value);
13
+ if (!Number.isFinite(number)) throw new Error(`${name} must be a number`);
14
+ return number;
15
+ }
16
+
17
+ export async function renderSvg(source, options = {}) {
18
+ const layout = options.layout ?? "dagre";
19
+ if (!["dagre", "elk"].includes(layout)) {
20
+ throw new Error(
21
+ `The bundled D2 renderer supports layout "dagre" or "elk", not "${layout}"`
22
+ );
23
+ }
24
+
25
+ const d2 = getRenderer();
26
+ const compileOptions = {
27
+ layout,
28
+ themeID: optionalNumber(options.theme, "theme"),
29
+ darkThemeID: optionalNumber(options.darkTheme, "darkTheme"),
30
+ sketch: options.sketch,
31
+ center: options.center,
32
+ pad: optionalNumber(options.pad, "pad"),
33
+ scale: optionalNumber(options.scale, "scale")
34
+ };
35
+ const result = await d2.compile(source, compileOptions);
36
+ const svg = await d2.render(result.diagram, result.renderOptions);
37
+
38
+ // @terrastruct/d2 currently keeps a Node worker alive. Unref preserves it for
39
+ // watch-mode reuse without preventing one-shot CLI invocations from exiting.
40
+ d2.worker?.unref?.();
41
+ return svg;
42
+ }