@liminis/diagrams 0.1.2 → 0.1.4
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 +13 -11
- package/dist/bin/render-c4.d.ts +20 -1
- package/dist/bin/render-c4.js +84 -2
- package/dist/core/layout.js +46 -29
- package/dist/playground/C4Playground.d.ts +43 -0
- package/dist/playground/C4Playground.js +204 -0
- package/dist/playground/playground.css +166 -0
- package/dist/playground/useIsDarkMode.d.ts +19 -0
- package/dist/playground/useIsDarkMode.js +46 -0
- package/dist/playground.d.ts +24 -0
- package/dist/playground.js +23 -0
- package/dist/react/C4InteractiveRenderer.d.ts +24 -1
- package/dist/react/C4InteractiveRenderer.js +8 -5
- package/dist/react/renderer.d.ts +23 -1
- package/dist/react/renderer.js +25 -2
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -7,13 +7,15 @@ editing.
|
|
|
7
7
|
Extracted from [`@liminis/editor`](https://github.com/verveguy/liminis-editor), where it
|
|
8
8
|
renders ` ```c4 ` fenced code blocks. Nothing here is bound to that editor.
|
|
9
9
|
|
|
10
|
+
Documentation: **[v3rv.com/liminis-diagrams](https://v3rv.com/liminis-diagrams/)**.
|
|
11
|
+
|
|
10
12
|
## Demo
|
|
11
13
|
|
|
12
|
-
**[https://v3rv.com/liminis-diagrams/](https://v3rv.com/liminis-diagrams/)** — edit
|
|
14
|
+
**[https://v3rv.com/liminis-diagrams/demo/](https://v3rv.com/liminis-diagrams/demo/)** — edit
|
|
13
15
|
C4-PlantUML source and see it re-render live, drag nodes to reposition them, toggle dark
|
|
14
16
|
mode, and switch between a few preset diagrams. The demo keeps dragged positions in
|
|
15
17
|
memory only, for as long as the tab is open — this package has no persistence of its
|
|
16
|
-
own (see [Recipe 3](
|
|
18
|
+
own (see [Recipe 3](https://v3rv.com/liminis-diagrams/recipes/#recipe-3-position-persistence--the-hosts-choice)),
|
|
17
19
|
and neither does this demo.
|
|
18
20
|
|
|
19
21
|
## Install
|
|
@@ -25,12 +27,12 @@ npm install @liminis/diagrams
|
|
|
25
27
|
`react` and `react-dom` are **optional** peers. Installing the package gets you
|
|
26
28
|
`@dagrejs/dagre` and nothing else, so `@liminis/diagrams/core` works in a CLI or CI job
|
|
27
29
|
with no React on disk. Install the peers if you use `/react` or `/server` — see
|
|
28
|
-
[
|
|
30
|
+
[Architecture](https://v3rv.com/liminis-diagrams/architecture/) for why the split exists and which entry
|
|
29
31
|
point to pick.
|
|
30
32
|
|
|
31
33
|
## Not sure this package does what you're assuming?
|
|
32
34
|
|
|
33
|
-
Read [Limitations](
|
|
35
|
+
Read [Limitations](https://v3rv.com/liminis-diagrams/#limitations--read-this-first) before you build
|
|
34
36
|
against this package. In short: no editing UI, no persistence, element IDs aren't
|
|
35
37
|
stable across diagrams, no cross-diagram links.
|
|
36
38
|
|
|
@@ -85,21 +87,21 @@ import { C4InteractiveRenderer } from '@liminis/diagrams/react';
|
|
|
85
87
|
|
|
86
88
|
Pass `manualPositions` to `layoutC4Diagram` to bypass dagre for the elements you have
|
|
87
89
|
positions for. Persisting them is entirely your call — see
|
|
88
|
-
[Recipe 3](
|
|
90
|
+
[Recipe 3](https://v3rv.com/liminis-diagrams/recipes/#recipe-3-position-persistence--the-hosts-choice) for a worked
|
|
89
91
|
example (including how `@liminis/editor` does it) and why this package itself never
|
|
90
92
|
writes them anywhere.
|
|
91
93
|
|
|
92
94
|
## Render on the command line
|
|
93
95
|
|
|
94
96
|
```bash
|
|
95
|
-
npx --package=@liminis/diagrams -- render-c4 diagram.puml
|
|
97
|
+
npx --package=@liminis/diagrams --package=react --package=react-dom -- render-c4 diagram.puml
|
|
96
98
|
# diagram.puml -> diagram.svg
|
|
97
99
|
```
|
|
98
100
|
|
|
99
101
|
Useful for pre-rendering diagrams so a plain `` is enough for
|
|
100
102
|
GitHub (or any markdown renderer) to show them — see
|
|
101
|
-
[
|
|
102
|
-
[
|
|
103
|
+
[Rendering diagrams on GitHub](https://v3rv.com/liminis-diagrams/github-integration/) for the CI recipe, and
|
|
104
|
+
[Rendering diagrams in Claude Code](https://v3rv.com/liminis-diagrams/claude-code-integration/) for getting Claude
|
|
103
105
|
to render real diagrams instead of hand-drawing them.
|
|
104
106
|
|
|
105
107
|
## Supported syntax
|
|
@@ -107,13 +109,13 @@ to render real diagrams instead of hand-drawing them.
|
|
|
107
109
|
`Person`, `System`, `Container`, `Component` and their `_Ext` / `Db` / `Queue` variants,
|
|
108
110
|
plus `Deployment_Node`, `Node`, and `InfrastructureNode` variants; boundary macros;
|
|
109
111
|
`Rel` (with directional variants) and `BiRel`. See
|
|
110
|
-
[
|
|
112
|
+
[the C4-PlantUML reference](https://v3rv.com/liminis-diagrams/dsl-reference/) for the full macro table and exactly
|
|
111
113
|
which directives (`@startuml`, `!include`, `SHOW_LEGEND()`, `LAYOUT_*`, …) are applied
|
|
112
114
|
versus silently stripped.
|
|
113
115
|
|
|
114
116
|
## Documentation
|
|
115
117
|
|
|
116
|
-
Building a tool on top of this package? [
|
|
118
|
+
Building a tool on top of this package? [the documentation site](https://v3rv.com/liminis-diagrams/) covers the
|
|
117
119
|
entry-point boundary, the full DSL reference, the data model, and runnable recipes for
|
|
118
120
|
headless rendering, embedding the interactive renderer, and position persistence.
|
|
119
121
|
|
|
@@ -122,7 +124,7 @@ headless rendering, embedding the interactive renderer, and position persistence
|
|
|
122
124
|
The commit history predates this repository: it was recovered from
|
|
123
125
|
`verveguy/liminis` (`liminis-app/src/editor/app/editor/c4/`, later
|
|
124
126
|
`packages/editor/src/app/editor/c4/`) and carries development from 2026-03-18 onward.
|
|
125
|
-
`git log --follow` works across the move.
|
|
127
|
+
`git log --follow` works across the move.
|
|
126
128
|
|
|
127
129
|
## License
|
|
128
130
|
|
package/dist/bin/render-c4.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* meant for pre-rendering diagrams in CI so that a plain `` in
|
|
7
7
|
* a markdown file is enough for GitHub (or any other markdown renderer) to show
|
|
8
8
|
* it — no live rendering service, no image-provider proxy, nothing to host.
|
|
9
|
-
* See
|
|
9
|
+
* See https://v3rv.com/liminis-diagrams/github-integration/ for the recipe this exists for.
|
|
10
10
|
*
|
|
11
11
|
* No dependency is added for argument parsing: flags are hand-rolled to match
|
|
12
12
|
* the style of the other scripts in this repo (guard-publish.mjs,
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export interface Options {
|
|
16
16
|
files: string[];
|
|
17
|
+
/** Extract ```c4 fences from markdown inputs instead of treating the whole file as source. */
|
|
18
|
+
fromMarkdown: boolean;
|
|
17
19
|
dark: boolean;
|
|
18
20
|
out?: string;
|
|
19
21
|
outDir?: string;
|
|
@@ -27,4 +29,21 @@ export declare function outputPathFor(inputPath: string, options: Options): stri
|
|
|
27
29
|
* (without the `render-c4: ` prefix `main` adds).
|
|
28
30
|
*/
|
|
29
31
|
export declare function validateStdinCombination(options: Options): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* ```c4 fences in a markdown file, with the 1-based line the fence body starts on
|
|
34
|
+
* so errors point at the real location in the file rather than at the snippet.
|
|
35
|
+
*
|
|
36
|
+
* Diagrams live in fenced blocks far more often than in standalone .puml files —
|
|
37
|
+
* that is how @liminis/editor stores them, how this package's own docs are
|
|
38
|
+
* written, and what a markdown-based diagram wiki produces. A renderer that only
|
|
39
|
+
* understood whole files would not fit the common case.
|
|
40
|
+
*
|
|
41
|
+
* A fence tagged `invalid` is skipped: a page documenting parse errors needs
|
|
42
|
+
* source that does not parse, and that is content rather than a defect.
|
|
43
|
+
*/
|
|
44
|
+
export declare function extractC4Fences(markdown: string): {
|
|
45
|
+
source: string;
|
|
46
|
+
line: number;
|
|
47
|
+
index: number;
|
|
48
|
+
}[];
|
|
30
49
|
export declare function renderFiles(options: Options): number;
|
package/dist/bin/render-c4.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* meant for pre-rendering diagrams in CI so that a plain `` in
|
|
7
7
|
* a markdown file is enough for GitHub (or any other markdown renderer) to show
|
|
8
8
|
* it — no live rendering service, no image-provider proxy, nothing to host.
|
|
9
|
-
* See
|
|
9
|
+
* See https://v3rv.com/liminis-diagrams/github-integration/ for the recipe this exists for.
|
|
10
10
|
*
|
|
11
11
|
* No dependency is added for argument parsing: flags are hand-rolled to match
|
|
12
12
|
* the style of the other scripts in this repo (guard-publish.mjs,
|
|
@@ -22,6 +22,8 @@ function printUsage() {
|
|
|
22
22
|
Render C4-PlantUML source files to SVG.
|
|
23
23
|
|
|
24
24
|
Options:
|
|
25
|
+
--from-markdown Read fenced c4 blocks out of markdown inputs rather than
|
|
26
|
+
treating each whole file as diagram source
|
|
25
27
|
--dark Render in dark mode
|
|
26
28
|
-o, --out <file> Output path (only valid with exactly one input file)
|
|
27
29
|
--out-dir <dir> Write outputs here, preserving basenames (.svg extension)
|
|
@@ -31,7 +33,7 @@ Options:
|
|
|
31
33
|
`);
|
|
32
34
|
}
|
|
33
35
|
export function parseArgs(argv) {
|
|
34
|
-
const options = { files: [], dark: false, check: false, stdin: false };
|
|
36
|
+
const options = { files: [], fromMarkdown: false, dark: false, check: false, stdin: false };
|
|
35
37
|
for (let i = 0; i < argv.length; i++) {
|
|
36
38
|
const arg = argv[i];
|
|
37
39
|
switch (arg) {
|
|
@@ -41,6 +43,9 @@ export function parseArgs(argv) {
|
|
|
41
43
|
case '--dark':
|
|
42
44
|
options.dark = true;
|
|
43
45
|
break;
|
|
46
|
+
case '--from-markdown':
|
|
47
|
+
options.fromMarkdown = true;
|
|
48
|
+
break;
|
|
44
49
|
case '--check':
|
|
45
50
|
options.check = true;
|
|
46
51
|
break;
|
|
@@ -92,7 +97,84 @@ export function validateStdinCombination(options) {
|
|
|
92
97
|
}
|
|
93
98
|
return null;
|
|
94
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* ```c4 fences in a markdown file, with the 1-based line the fence body starts on
|
|
102
|
+
* so errors point at the real location in the file rather than at the snippet.
|
|
103
|
+
*
|
|
104
|
+
* Diagrams live in fenced blocks far more often than in standalone .puml files —
|
|
105
|
+
* that is how @liminis/editor stores them, how this package's own docs are
|
|
106
|
+
* written, and what a markdown-based diagram wiki produces. A renderer that only
|
|
107
|
+
* understood whole files would not fit the common case.
|
|
108
|
+
*
|
|
109
|
+
* A fence tagged `invalid` is skipped: a page documenting parse errors needs
|
|
110
|
+
* source that does not parse, and that is content rather than a defect.
|
|
111
|
+
*/
|
|
112
|
+
export function extractC4Fences(markdown) {
|
|
113
|
+
const out = [];
|
|
114
|
+
const fence = /^```c4([^\n]*)\n([\s\S]*?)\n```$/gm;
|
|
115
|
+
let index = 0;
|
|
116
|
+
for (const match of markdown.matchAll(fence)) {
|
|
117
|
+
if (/\binvalid\b/.test(match[1] ?? ''))
|
|
118
|
+
continue;
|
|
119
|
+
index++;
|
|
120
|
+
const before = markdown.slice(0, match.index ?? 0);
|
|
121
|
+
out.push({
|
|
122
|
+
source: match[2],
|
|
123
|
+
line: before.split('\n').length + 1,
|
|
124
|
+
index,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function renderMarkdownFences(options) {
|
|
130
|
+
let failures = 0;
|
|
131
|
+
for (const inputPath of options.files) {
|
|
132
|
+
let markdown;
|
|
133
|
+
try {
|
|
134
|
+
markdown = readFileSync(inputPath, 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
failures++;
|
|
138
|
+
console.error(`${inputPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const fences = extractC4Fences(markdown);
|
|
142
|
+
if (fences.length === 0)
|
|
143
|
+
continue;
|
|
144
|
+
for (const fence of fences) {
|
|
145
|
+
const { svg, errors } = renderC4DiagramToSVG(fence.source, options.dark);
|
|
146
|
+
if (errors.length > 0) {
|
|
147
|
+
failures++;
|
|
148
|
+
for (const error of errors) {
|
|
149
|
+
// Offset into the containing file, so the message is navigable.
|
|
150
|
+
console.error(`${inputPath}:${fence.line + error.line - 1}:${error.column}: ${error.message}`);
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (options.check)
|
|
155
|
+
continue;
|
|
156
|
+
const base = basename(inputPath, extname(inputPath));
|
|
157
|
+
const svgName = `${base}-${fence.index}.svg`;
|
|
158
|
+
const outPath = options.outDir
|
|
159
|
+
? join(options.outDir, svgName)
|
|
160
|
+
: join(dirname(inputPath), svgName);
|
|
161
|
+
try {
|
|
162
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
163
|
+
writeFileSync(outPath, svg);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
failures++;
|
|
167
|
+
console.error(`${inputPath}: failed to write ${outPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
console.log(`${inputPath} [${fence.index}] -> ${outPath}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return failures > 0 ? 2 : 0;
|
|
174
|
+
}
|
|
95
175
|
export function renderFiles(options) {
|
|
176
|
+
if (options.fromMarkdown)
|
|
177
|
+
return renderMarkdownFences(options);
|
|
96
178
|
if (options.out && options.files.length > 1) {
|
|
97
179
|
console.error('render-c4: -o/--out only applies with a single input file');
|
|
98
180
|
return 1;
|
package/dist/core/layout.js
CHANGED
|
@@ -757,29 +757,63 @@ function layoutWithManualPositions(diagram, options, manualPositions) {
|
|
|
757
757
|
}
|
|
758
758
|
// Calculate edges using existing function
|
|
759
759
|
const edges = calculateEdges(diagram.relationships, nodeMap);
|
|
760
|
-
|
|
761
|
-
|
|
760
|
+
return { nodes: allNodes, edges, ...boundsFor(allNodes, edges) };
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* The viewBox and dimensions that actually contain the laid-out diagram.
|
|
764
|
+
*
|
|
765
|
+
* Shared by both layout paths, because they were only ever different by
|
|
766
|
+
* accident. The manual path always computed a real bounding box — dragging a
|
|
767
|
+
* node above or left of the origin produces negative coordinates, and the
|
|
768
|
+
* viewBox origin moves to cover them rather than shifting every node, which
|
|
769
|
+
* would desynchronise rendered positions from the positions a host persists.
|
|
770
|
+
*
|
|
771
|
+
* The auto path hardcoded `viewBoxX: 0, viewBoxY: 0` on the premise that dagre
|
|
772
|
+
* never emits negative coordinates. That premise was wrong, and documented as
|
|
773
|
+
* fact in data-model.md. Dagre's own output is non-negative, but the
|
|
774
|
+
* cross-boundary alignment pass that runs after it shifts elements outside a
|
|
775
|
+
* boundary to line up with their targets inside it, and that can move them left
|
|
776
|
+
* of the origin. The result was a `viewBox` starting at 0 with content at
|
|
777
|
+
* negative x: clipped, and unreachable by scrolling, because a viewBox is a
|
|
778
|
+
* window rather than a canvas. Four lines of C4 reproduce it — a Person outside
|
|
779
|
+
* a System_Boundary with a Rel to something inside.
|
|
780
|
+
*
|
|
781
|
+
* Edge points are included as well as node rects: an edge routed around a
|
|
782
|
+
* boundary can leave the union of the node rectangles.
|
|
783
|
+
*
|
|
784
|
+
* Diagrams that were already correct are unaffected. Where nothing sits within
|
|
785
|
+
* BOUNDARY_PADDING of the origin, `Math.min(0, …)` yields 0 and the width and
|
|
786
|
+
* height are what they were.
|
|
787
|
+
*/
|
|
788
|
+
function boundsFor(nodes, edges) {
|
|
762
789
|
let minX = Infinity;
|
|
763
790
|
let minY = Infinity;
|
|
764
791
|
let maxX = 0;
|
|
765
792
|
let maxY = 0;
|
|
766
|
-
for (const node of
|
|
793
|
+
for (const node of nodes) {
|
|
767
794
|
minX = Math.min(minX, node.x);
|
|
768
795
|
minY = Math.min(minY, node.y);
|
|
769
796
|
maxX = Math.max(maxX, node.x + node.width);
|
|
770
797
|
maxY = Math.max(maxY, node.y + node.height);
|
|
771
798
|
}
|
|
772
|
-
|
|
773
|
-
|
|
799
|
+
for (const edge of edges) {
|
|
800
|
+
for (const point of edge.points) {
|
|
801
|
+
minX = Math.min(minX, point.x);
|
|
802
|
+
minY = Math.min(minY, point.y);
|
|
803
|
+
maxX = Math.max(maxX, point.x);
|
|
804
|
+
maxY = Math.max(maxY, point.y);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// An empty diagram leaves the minima at Infinity; treat it as the origin.
|
|
808
|
+
if (!Number.isFinite(minX))
|
|
809
|
+
minX = 0;
|
|
810
|
+
if (!Number.isFinite(minY))
|
|
811
|
+
minY = 0;
|
|
774
812
|
const viewBoxX = Math.min(0, minX - BOUNDARY_PADDING);
|
|
775
813
|
const viewBoxY = Math.min(0, minY - BOUNDARY_PADDING);
|
|
776
|
-
const width = maxX + BOUNDARY_PADDING - viewBoxX;
|
|
777
|
-
const height = maxY + BOUNDARY_PADDING - viewBoxY;
|
|
778
814
|
return {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
width,
|
|
782
|
-
height,
|
|
815
|
+
width: maxX + BOUNDARY_PADDING - viewBoxX,
|
|
816
|
+
height: maxY + BOUNDARY_PADDING - viewBoxY,
|
|
783
817
|
viewBoxX,
|
|
784
818
|
viewBoxY,
|
|
785
819
|
};
|
|
@@ -826,24 +860,7 @@ export function layoutC4Diagram(diagram, options, manualPositions) {
|
|
|
826
860
|
alignCrossBoundaryElements(layoutNodes, diagram.relationships, nodeMap);
|
|
827
861
|
// Calculate edges
|
|
828
862
|
const edges = calculateEdges(diagram.relationships, nodeMap);
|
|
829
|
-
|
|
830
|
-
let width = 0;
|
|
831
|
-
let height = 0;
|
|
832
|
-
for (const node of allNodes) {
|
|
833
|
-
width = Math.max(width, node.x + node.width);
|
|
834
|
-
height = Math.max(height, node.y + node.height);
|
|
835
|
-
}
|
|
836
|
-
// Add margin
|
|
837
|
-
width += BOUNDARY_PADDING;
|
|
838
|
-
height += BOUNDARY_PADDING;
|
|
839
|
-
const result = {
|
|
840
|
-
nodes: allNodes,
|
|
841
|
-
edges,
|
|
842
|
-
width,
|
|
843
|
-
height,
|
|
844
|
-
viewBoxX: 0,
|
|
845
|
-
viewBoxY: 0,
|
|
846
|
-
};
|
|
863
|
+
const result = { nodes: allNodes, edges, ...boundsFor(allNodes, edges) };
|
|
847
864
|
roundGeometryInPlace(result);
|
|
848
865
|
return result;
|
|
849
866
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ready-made C4 playground: source on one side, a live diagram on the other.
|
|
3
|
+
*
|
|
4
|
+
* The editing shell this package's own documentation uses, and the one every
|
|
5
|
+
* Liminis documentation site used to keep its own copy of. Five identical
|
|
6
|
+
* copies drifted apart the moment the renderer gained a feature — zoom shipped
|
|
7
|
+
* in 0.1.3 and four of the five sites had no way to reach it — which is what
|
|
8
|
+
* moved it in here.
|
|
9
|
+
*
|
|
10
|
+
* Styling comes from `@liminis/diagrams/playground.css`, which must be imported
|
|
11
|
+
* separately. It ships working defaults and reads a handful of `--c4-*` custom
|
|
12
|
+
* properties, so a host can make it look native in a few lines rather than
|
|
13
|
+
* overriding rules.
|
|
14
|
+
*
|
|
15
|
+
* Mount it client-side only. The drag layer measures the live SVG through
|
|
16
|
+
* `getScreenCTM`, which does not exist during a server render — under Astro
|
|
17
|
+
* that means `client:only="react"` rather than `client:visible`.
|
|
18
|
+
*/
|
|
19
|
+
export interface C4PlaygroundProps {
|
|
20
|
+
/** Initial C4-PlantUML source. */
|
|
21
|
+
source: string;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the reader may drag nodes. `false` is a fixed illustration: drag is
|
|
24
|
+
* off and there is no control to turn it on. Anything else starts with drag
|
|
25
|
+
* enabled and offers the toggle.
|
|
26
|
+
*/
|
|
27
|
+
editable?: boolean;
|
|
28
|
+
/** Hide the source pane — for diagrams that illustrate rather than invite editing. */
|
|
29
|
+
readOnly?: boolean;
|
|
30
|
+
/** Height of the diagram pane when inline, in CSS units. */
|
|
31
|
+
height?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Whether to draw in dark mode.
|
|
34
|
+
*
|
|
35
|
+
* A prop rather than something detected here, for the same reason
|
|
36
|
+
* `C4InteractiveRenderer` takes one: this package has no idea how its host
|
|
37
|
+
* decides what "dark" means. A host following the `data-theme` convention can
|
|
38
|
+
* use the `useIsDarkMode` hook exported alongside this component and pass the
|
|
39
|
+
* result straight in; a host with its own theming passes its own answer.
|
|
40
|
+
*/
|
|
41
|
+
isDarkMode?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export default function C4Playground({ source, editable, readOnly, height, isDarkMode, }: C4PlaygroundProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { parseC4, validateC4 } from '../core/parser.js';
|
|
5
|
+
import { C4InteractiveRenderer } from '../react/C4InteractiveRenderer.js';
|
|
6
|
+
import { C4ErrorDisplay } from '../react/renderer.js';
|
|
7
|
+
/**
|
|
8
|
+
* Zoom steps, rather than a continuous factor.
|
|
9
|
+
*
|
|
10
|
+
* A diagram is not a photograph: there is a size at which its labels are legible
|
|
11
|
+
* and sizes either side of it that are not, so the useful range is small and
|
|
12
|
+
* discrete. Steps also keep the control to two buttons and a readout, which is
|
|
13
|
+
* the entire UI anyone needs here.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Zoom steps, as multiples of the fitted size.
|
|
17
|
+
*
|
|
18
|
+
* 100% is the view that shows the whole diagram, not 1 SVG unit per pixel.
|
|
19
|
+
* That is the size the reader arrives at, so it is the one the number should
|
|
20
|
+
* describe — and it keeps the readout still when the lightbox opens, where the
|
|
21
|
+
* diagram is drawn larger but is still, conceptually, the same whole-diagram
|
|
22
|
+
* view. Reporting absolute scale made expanding look like a zoom: 57% became
|
|
23
|
+
* 100% while nothing about what you could see had changed.
|
|
24
|
+
*
|
|
25
|
+
* The range starts at fit because there is nothing below it worth seeing. Once
|
|
26
|
+
* the whole diagram is visible, smaller only buys whitespace.
|
|
27
|
+
*/
|
|
28
|
+
const ZOOM_STEPS = [1, 1.5, 2, 3, 4];
|
|
29
|
+
/**
|
|
30
|
+
* The scale at which the whole diagram is visible in the pane it is given.
|
|
31
|
+
*
|
|
32
|
+
* Never above 1. A diagram larger than its pane should shrink to fit, but a
|
|
33
|
+
* small one blown up to fill a lightbox is a surprise: nothing was gained and
|
|
34
|
+
* the reader now has to work out what the size means. Fitting is about seeing
|
|
35
|
+
* all of it, not about filling space.
|
|
36
|
+
*
|
|
37
|
+
* Returns null when there is nothing to measure yet — before hydration, or if
|
|
38
|
+
* the pane has no size because it is display:none.
|
|
39
|
+
*/
|
|
40
|
+
function fitScale(canvas) {
|
|
41
|
+
const svg = canvas?.querySelector('svg');
|
|
42
|
+
if (!canvas || !svg)
|
|
43
|
+
return null;
|
|
44
|
+
const { width: diagramWidth, height: diagramHeight } = svg.viewBox.baseVal;
|
|
45
|
+
if (!diagramWidth || !diagramHeight)
|
|
46
|
+
return null;
|
|
47
|
+
// The pane's usable space, less its own padding — measuring the border box
|
|
48
|
+
// would overshoot by the padding and clip what fitting is meant to reveal.
|
|
49
|
+
const style = getComputedStyle(canvas);
|
|
50
|
+
// `|| 0` on each: a computed padding is always a px string in a browser, but
|
|
51
|
+
// it can be empty where no stylesheet has been applied, and `parseFloat('')`
|
|
52
|
+
// is NaN. One NaN makes the whole fit NaN, which reads as "no fitting at all"
|
|
53
|
+
// rather than as an error — the diagram silently opens at actual size.
|
|
54
|
+
const px = (value) => parseFloat(value) || 0;
|
|
55
|
+
const available = {
|
|
56
|
+
width: canvas.clientWidth - px(style.paddingLeft) - px(style.paddingRight),
|
|
57
|
+
height: canvas.clientHeight - px(style.paddingTop) - px(style.paddingBottom),
|
|
58
|
+
};
|
|
59
|
+
if (!(available.width > 0) || !(available.height > 0))
|
|
60
|
+
return null;
|
|
61
|
+
return Math.min(1, available.width / diagramWidth, available.height / diagramHeight);
|
|
62
|
+
}
|
|
63
|
+
export default function C4Playground({ source, editable = true, readOnly = false, height = '22rem', isDarkMode = false, }) {
|
|
64
|
+
const [text, setText] = useState(source.trim());
|
|
65
|
+
const [positions, setPositions] = useState({});
|
|
66
|
+
const [isEditMode, setIsEditMode] = useState(editable);
|
|
67
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
68
|
+
// Zoom as a multiple of the fitted size: 1 is "the whole diagram", which is
|
|
69
|
+
// where every view starts. The absolute scale handed to the renderer is this
|
|
70
|
+
// times whatever fitting currently works out to.
|
|
71
|
+
const [relativeZoom, setRelativeZoom] = useState(1);
|
|
72
|
+
const [fittedZoom, setFittedZoom] = useState(1);
|
|
73
|
+
const canvasRef = useRef(null);
|
|
74
|
+
const panelRef = useRef(null);
|
|
75
|
+
const returnFocusTo = useRef(null);
|
|
76
|
+
// Escape closes; the page behind must not scroll while the lightbox is open.
|
|
77
|
+
//
|
|
78
|
+
// Expanding is a CSS overlay rather than the Fullscreen API on purpose: iOS
|
|
79
|
+
// Safari implements `requestFullscreen` for video only, so the API is a no-op
|
|
80
|
+
// on an iPad — a device this package's touch support exists for.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!isExpanded)
|
|
83
|
+
return;
|
|
84
|
+
// Where focus came from, so closing puts it back rather than dumping the
|
|
85
|
+
// reader at the top of the document.
|
|
86
|
+
returnFocusTo.current = document.activeElement;
|
|
87
|
+
const panel = panelRef.current;
|
|
88
|
+
const focusable = () => Array.from(panel?.querySelectorAll('button, [href], input, textarea, select, [tabindex]:not([tabindex="-1"])') ?? []).filter((el) => !el.hasAttribute('disabled'));
|
|
89
|
+
focusable()[0]?.focus();
|
|
90
|
+
// `aria-modal` promises the rest of the page is unreachable, and a promise
|
|
91
|
+
// the markup does not keep is worse than not making it: a keyboard user
|
|
92
|
+
// tabs into content hidden behind the backdrop with no way to tell where
|
|
93
|
+
// they are. Tab is cycled within the panel to make it true.
|
|
94
|
+
const onKey = (e) => {
|
|
95
|
+
if (e.key === 'Escape') {
|
|
96
|
+
setIsExpanded(false);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (e.key !== 'Tab')
|
|
100
|
+
return;
|
|
101
|
+
const items = focusable();
|
|
102
|
+
if (items.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
const first = items[0];
|
|
105
|
+
const last = items[items.length - 1];
|
|
106
|
+
// Focus can be outside the panel entirely — the browser's own UI hands it
|
|
107
|
+
// back to the document, an extension moves it. Both directions have to
|
|
108
|
+
// catch that case, or Tab walks into the page behind the backdrop, which
|
|
109
|
+
// is exactly what aria-modal promises cannot happen.
|
|
110
|
+
const active = document.activeElement;
|
|
111
|
+
const escaped = !panel?.contains(active);
|
|
112
|
+
if (e.shiftKey && (escaped || active === first)) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
last.focus();
|
|
115
|
+
}
|
|
116
|
+
else if (!e.shiftKey && (escaped || active === last)) {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
first.focus();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener('keydown', onKey);
|
|
122
|
+
const previous = document.body.style.overflow;
|
|
123
|
+
document.body.style.overflow = 'hidden';
|
|
124
|
+
return () => {
|
|
125
|
+
window.removeEventListener('keydown', onKey);
|
|
126
|
+
document.body.style.overflow = previous;
|
|
127
|
+
// Guarded: the element may have been unmounted while the panel was open.
|
|
128
|
+
const target = returnFocusTo.current;
|
|
129
|
+
if (target instanceof HTMLElement && target.isConnected)
|
|
130
|
+
target.focus();
|
|
131
|
+
};
|
|
132
|
+
}, [isExpanded]);
|
|
133
|
+
// Parse on every keystroke. The parser is pure and fast enough that
|
|
134
|
+
// debouncing would add latency without buying anything.
|
|
135
|
+
const parsed = useMemo(() => {
|
|
136
|
+
const result = parseC4(text);
|
|
137
|
+
if (result.diagram)
|
|
138
|
+
result.errors.push(...validateC4(result.diagram));
|
|
139
|
+
return result;
|
|
140
|
+
}, [text]);
|
|
141
|
+
// What the renderer is actually given. Fitting produces an arbitrary scale —
|
|
142
|
+
// 0.57 on this site's architecture page — and the reader's multiple applies
|
|
143
|
+
// on top of it.
|
|
144
|
+
const zoom = fittedZoom * relativeZoom;
|
|
145
|
+
const stepZoom = useCallback((direction) => {
|
|
146
|
+
setRelativeZoom((current) => {
|
|
147
|
+
const next = direction === 1
|
|
148
|
+
? ZOOM_STEPS.find((step) => step > current + 0.001)
|
|
149
|
+
: [...ZOOM_STEPS].reverse().find((step) => step < current - 0.001);
|
|
150
|
+
return next ?? current;
|
|
151
|
+
});
|
|
152
|
+
}, []);
|
|
153
|
+
// Declared before the effect below, which depends on it: the source pane's
|
|
154
|
+
// presence changes how much width the diagram has to fit into.
|
|
155
|
+
const showSource = !readOnly;
|
|
156
|
+
// Measure after layout rather than after paint, so the diagram is never shown
|
|
157
|
+
// at the wrong size for a frame and then corrected — which reads as a flinch.
|
|
158
|
+
useLayoutEffect(() => {
|
|
159
|
+
const canvas = canvasRef.current;
|
|
160
|
+
if (!canvas)
|
|
161
|
+
return;
|
|
162
|
+
const measure = () => {
|
|
163
|
+
const fit = fitScale(canvas);
|
|
164
|
+
if (fit !== null)
|
|
165
|
+
setFittedZoom(fit);
|
|
166
|
+
};
|
|
167
|
+
measure();
|
|
168
|
+
// The pane resizes when the window does, when the lightbox opens, and when
|
|
169
|
+
// the source pane is shown or hidden. Observing it covers all three without
|
|
170
|
+
// enumerating them.
|
|
171
|
+
const observer = new ResizeObserver(measure);
|
|
172
|
+
observer.observe(canvas);
|
|
173
|
+
return () => observer.disconnect();
|
|
174
|
+
// `text` is a dependency because editing the source changes the diagram's
|
|
175
|
+
// dimensions, and `isExpanded` because the pane it has to fit changes.
|
|
176
|
+
}, [text, isExpanded, showSource]);
|
|
177
|
+
// Expanding is a request to see the whole diagram, so it returns to fitting
|
|
178
|
+
// even if the reader had zoomed in beforehand. Collapsing does the same, since
|
|
179
|
+
// the inline pane is a different size again.
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
setRelativeZoom(1);
|
|
182
|
+
}, [isExpanded]);
|
|
183
|
+
const positionCount = Object.keys(positions).length;
|
|
184
|
+
const panel = (_jsxs("div", { ref: panelRef, className: 'c4-playground not-content' +
|
|
185
|
+
(isExpanded ? ' c4-playground--expanded' : '') +
|
|
186
|
+
(showSource ? '' : ' c4-playground--diagram-only'), role: isExpanded ? 'dialog' : undefined, "aria-modal": isExpanded || undefined, "aria-label": isExpanded ? 'C4 diagram, expanded' : undefined, children: [_jsxs("div", { className: "c4-playground__bar", children: [editable && (_jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: isEditMode, onChange: (e) => setIsEditMode(e.target.checked) }), ' ', "Drag to reposition"] })), editable && (_jsx("button", { type: "button", onClick: () => setPositions({}), disabled: positionCount === 0, children: "Reset layout" })), _jsx("span", { className: "c4-playground__hint", children: !editable
|
|
187
|
+
? 'laid out by dagre'
|
|
188
|
+
: positionCount > 0
|
|
189
|
+
? `${positionCount} positions held in memory`
|
|
190
|
+
: 'laid out by dagre' }), _jsxs("div", { className: "c4-playground__zoom", children: [_jsx("button", { type: "button", onClick: () => stepZoom(-1), disabled: relativeZoom <= ZOOM_STEPS[0] + 0.001, "aria-label": "Zoom out", title: "Zoom out", children: "\u2212" }), _jsxs("button", { type: "button", onClick: () => setRelativeZoom(1), disabled: relativeZoom === 1, "aria-label": "Zoom to fit", title: "Zoom to fit", children: [Math.round(relativeZoom * 100), "%"] }), _jsx("button", { type: "button", onClick: () => stepZoom(1), disabled: relativeZoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1] - 0.001, "aria-label": "Zoom in", title: "Zoom in", children: "+" })] }), _jsx("button", { type: "button", className: "c4-playground__expand", onClick: () => setIsExpanded((v) => !v), "aria-pressed": isExpanded, "aria-label": isExpanded ? 'Close expanded diagram' : 'Expand diagram', title: isExpanded ? 'Close (Esc)' : 'Expand', children: isExpanded ? '✕' : '⤡' })] }), _jsxs("div", { className: "c4-playground__panes", style: { minHeight: isExpanded ? undefined : height }, children: [showSource && (_jsx("textarea", { className: "c4-playground__source", value: text, spellCheck: false, onChange: (e) => setText(e.target.value), "aria-label": "C4-PlantUML source" })), _jsx("div", { className: "c4-playground__canvas", ref: canvasRef, children: parsed.diagram && parsed.errors.length === 0 ? (_jsx(C4InteractiveRenderer, { diagram: parsed.diagram, isDarkMode: isDarkMode, isEditMode: editable && isEditMode, manualPositions: positions, onPositionChange: setPositions, zoom: zoom })) : (_jsx(C4ErrorDisplay, { errors: parsed.errors, isDarkMode: isDarkMode })) })] })] }));
|
|
191
|
+
if (!isExpanded)
|
|
192
|
+
return panel;
|
|
193
|
+
// Portalled to <body> rather than rendered in place. `position: fixed` resolves
|
|
194
|
+
// against the nearest ancestor with a transform, filter or containment rather
|
|
195
|
+
// than the viewport, and z-index is confined to that ancestor's stacking
|
|
196
|
+
// context — which is why the first attempt sat *under* Starlight's header and
|
|
197
|
+
// table of contents no matter how high its z-index went. Escaping the content
|
|
198
|
+
// tree is the fix; raising the number is not.
|
|
199
|
+
//
|
|
200
|
+
// The backdrop is a real element rather than a pseudo-element so clicking
|
|
201
|
+
// outside closes. The panel is its sibling, not its child, so a click inside
|
|
202
|
+
// the panel never reaches the backdrop's handler.
|
|
203
|
+
return createPortal(_jsxs("div", { className: "c4-playground__lightbox", children: [_jsx("div", { className: "c4-playground__backdrop", onClick: () => setIsExpanded(false), "aria-hidden": "true" }), panel] }), document.body);
|
|
204
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/* Styles for C4Playground (@liminis/diagrams/playground).
|
|
2
|
+
*
|
|
3
|
+
* Import once, anywhere in your app:
|
|
4
|
+
*
|
|
5
|
+
* import '@liminis/diagrams/playground.css'
|
|
6
|
+
*
|
|
7
|
+
* Every colour, font and size below resolves through a `--c4-*` custom property
|
|
8
|
+
* with a working default, so this looks reasonable in a page that does nothing.
|
|
9
|
+
* To make it look native, set the properties — not the rules:
|
|
10
|
+
*
|
|
11
|
+
* .c4-playground {
|
|
12
|
+
* --c4-border: var(--sl-color-gray-5);
|
|
13
|
+
* --c4-bg: var(--sl-color-bg);
|
|
14
|
+
* --c4-muted: var(--sl-color-gray-3);
|
|
15
|
+
* --c4-font-mono: var(--sl-font-mono);
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Overriding properties survives an upgrade in a way overriding rules does not.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
.c4-playground {
|
|
22
|
+
/* Defaults, deliberately plain. A host that sets nothing still gets something
|
|
23
|
+
legible in both colour schemes; a host that sets these gets its own look. */
|
|
24
|
+
--c4-border: #d4d4d8;
|
|
25
|
+
--c4-bg: #ffffff;
|
|
26
|
+
--c4-muted: #6b7280;
|
|
27
|
+
--c4-accent: #3b82f6;
|
|
28
|
+
--c4-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
29
|
+
--c4-text-sm: 0.875rem;
|
|
30
|
+
--c4-text-xs: 0.75rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@media (prefers-color-scheme: dark) {
|
|
34
|
+
.c4-playground {
|
|
35
|
+
--c4-border: #3f3f46;
|
|
36
|
+
--c4-bg: #18181b;
|
|
37
|
+
--c4-muted: #a1a1aa;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.c4-playground {
|
|
42
|
+
border: 1px solid var(--c4-border);
|
|
43
|
+
border-radius: 0.5rem;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
margin: 1.5rem 0;
|
|
46
|
+
background: var(--c4-bg);
|
|
47
|
+
}
|
|
48
|
+
.c4-playground__bar {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 1rem;
|
|
52
|
+
padding: 0.5rem 0.75rem;
|
|
53
|
+
border-bottom: 1px solid var(--c4-border);
|
|
54
|
+
font-size: var(--c4-text-sm);
|
|
55
|
+
flex-wrap: wrap;
|
|
56
|
+
}
|
|
57
|
+
.c4-playground__bar label { display: inline-flex; align-items: center; gap: 0.35rem; }
|
|
58
|
+
.c4-playground__bar button {
|
|
59
|
+
border: 1px solid var(--c4-border);
|
|
60
|
+
background: transparent;
|
|
61
|
+
color: inherit;
|
|
62
|
+
border-radius: 0.3rem;
|
|
63
|
+
padding: 0.15rem 0.6rem;
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
font: inherit;
|
|
66
|
+
}
|
|
67
|
+
.c4-playground__bar button:disabled { opacity: 0.45; cursor: default; }
|
|
68
|
+
.c4-playground__hint { color: var(--c4-muted); }
|
|
69
|
+
.c4-playground__panes { display: grid; grid-template-columns: minmax(0, 20rem) minmax(0, 1fr); }
|
|
70
|
+
@media (max-width: 50rem) { .c4-playground__panes { grid-template-columns: 1fr; } }
|
|
71
|
+
.c4-playground__source {
|
|
72
|
+
border: 0;
|
|
73
|
+
border-right: 1px solid var(--c4-border);
|
|
74
|
+
padding: 0.75rem;
|
|
75
|
+
font-family: var(--c4-font-mono);
|
|
76
|
+
font-size: var(--c4-text-xs);
|
|
77
|
+
line-height: 1.5;
|
|
78
|
+
resize: vertical;
|
|
79
|
+
background: transparent;
|
|
80
|
+
color: inherit;
|
|
81
|
+
min-height: 100%;
|
|
82
|
+
}
|
|
83
|
+
.c4-playground__source:focus { outline: 2px solid var(--c4-accent); outline-offset: -2px; }
|
|
84
|
+
.c4-playground__canvas {
|
|
85
|
+
overflow: auto;
|
|
86
|
+
padding: 0.5rem;
|
|
87
|
+
display: flex;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Centred with auto margins, never `justify-content`/`align-items: center`.
|
|
91
|
+
Those centre the overflow too: a diagram wider than the pane spills equally
|
|
92
|
+
left and right, and the left half cannot be scrolled to — the scroll origin
|
|
93
|
+
is the container's start edge, which the centring has already moved content
|
|
94
|
+
past. It reads as a diagram with its left side cut off and no way to reach
|
|
95
|
+
it, because that is exactly what it is.
|
|
96
|
+
|
|
97
|
+
Auto margins collapse to zero once there is no free space, so the same rule
|
|
98
|
+
centres a small diagram and lets a large one start at its true origin and
|
|
99
|
+
scroll. The child here is the renderer's own positioning wrapper, not the
|
|
100
|
+
<svg>: that wrapper is the flex item, which is why a `max-width` on the svg
|
|
101
|
+
never constrained anything. */
|
|
102
|
+
.c4-playground__canvas > * { margin: auto; }
|
|
103
|
+
|
|
104
|
+
/* Lightbox. The first attempt was inset:0 with no backdrop — a takeover, not a
|
|
105
|
+
modal. The second used absolute + inset on the panel, which let the content
|
|
106
|
+
dictate the height and pushed it past the viewport.
|
|
107
|
+
|
|
108
|
+
This version centres with flex and puts the margin on the *container* as
|
|
109
|
+
padding, so a gap on all four sides is guaranteed regardless of content. */
|
|
110
|
+
.c4-playground__lightbox {
|
|
111
|
+
position: fixed;
|
|
112
|
+
inset: 0;
|
|
113
|
+
/* Above Starlight's header (z-index 10) and mobile nav. A body child, so
|
|
114
|
+
this number is not competing inside a nested stacking context. */
|
|
115
|
+
z-index: 9999;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
padding: 4vh 4vw;
|
|
120
|
+
}
|
|
121
|
+
.c4-playground__backdrop {
|
|
122
|
+
position: absolute;
|
|
123
|
+
inset: 0;
|
|
124
|
+
background: rgb(0 0 0 / 0.55);
|
|
125
|
+
backdrop-filter: blur(2px);
|
|
126
|
+
}
|
|
127
|
+
.c4-playground--expanded {
|
|
128
|
+
position: relative;
|
|
129
|
+
width: 100%;
|
|
130
|
+
height: 100%;
|
|
131
|
+
max-width: 90rem;
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
border-radius: 0.75rem;
|
|
135
|
+
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.45);
|
|
136
|
+
background: var(--c4-bg);
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
}
|
|
139
|
+
.c4-playground--expanded .c4-playground__panes { flex: 1; min-height: 0; }
|
|
140
|
+
.c4-playground--expanded .c4-playground__source,
|
|
141
|
+
.c4-playground--expanded .c4-playground__canvas { min-height: 0; overflow: auto; }
|
|
142
|
+
|
|
143
|
+
/* Diagram-only: an illustration, not an invitation to edit. */
|
|
144
|
+
.c4-playground--diagram-only .c4-playground__panes { grid-template-columns: 1fr; }
|
|
145
|
+
|
|
146
|
+
.c4-playground__zoom {
|
|
147
|
+
display: inline-flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
gap: 0.15rem;
|
|
150
|
+
margin-left: auto;
|
|
151
|
+
}
|
|
152
|
+
.c4-playground__zoom button {
|
|
153
|
+
min-width: 2rem;
|
|
154
|
+
padding: 0.2rem 0.4rem;
|
|
155
|
+
line-height: 1;
|
|
156
|
+
font-variant-numeric: tabular-nums;
|
|
157
|
+
}
|
|
158
|
+
/* The readout doubles as the reset control, disabled at 100% rather than
|
|
159
|
+
hidden — a control that vanishes when it does nothing is one the reader has
|
|
160
|
+
to rediscover. Disabled styling comes from the bar's own button rule. */
|
|
161
|
+
|
|
162
|
+
.c4-playground__expand {
|
|
163
|
+
margin-left: 0.25rem;
|
|
164
|
+
line-height: 1;
|
|
165
|
+
padding: 0.2rem 0.5rem;
|
|
166
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track the site's current theme.
|
|
3
|
+
*
|
|
4
|
+
* `C4InteractiveRenderer` takes `isDarkMode` as a prop rather than reading the
|
|
5
|
+
* document itself — deliberately, since the library has no idea how its host
|
|
6
|
+
* decides what "dark" means. That makes keeping it current the host's job, and
|
|
7
|
+
* a host that reads the theme once at mount has a diagram that keeps last
|
|
8
|
+
* night's colours until something else forces a re-render. Clicking one used to
|
|
9
|
+
* be what did it here, which is not a feature.
|
|
10
|
+
*
|
|
11
|
+
* Two sources have to be watched, because Starlight has three states:
|
|
12
|
+
*
|
|
13
|
+
* - An explicit choice sets `data-theme="dark"|"light"` on `<html>`, so the
|
|
14
|
+
* attribute is observed.
|
|
15
|
+
* - The default "auto" sets nothing, and the theme follows the OS. So
|
|
16
|
+
* `prefers-color-scheme` is watched too — otherwise a diagram would not
|
|
17
|
+
* follow the system flipping to dark at sunset.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useIsDarkMode(): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Track the site's current theme.
|
|
4
|
+
*
|
|
5
|
+
* `C4InteractiveRenderer` takes `isDarkMode` as a prop rather than reading the
|
|
6
|
+
* document itself — deliberately, since the library has no idea how its host
|
|
7
|
+
* decides what "dark" means. That makes keeping it current the host's job, and
|
|
8
|
+
* a host that reads the theme once at mount has a diagram that keeps last
|
|
9
|
+
* night's colours until something else forces a re-render. Clicking one used to
|
|
10
|
+
* be what did it here, which is not a feature.
|
|
11
|
+
*
|
|
12
|
+
* Two sources have to be watched, because Starlight has three states:
|
|
13
|
+
*
|
|
14
|
+
* - An explicit choice sets `data-theme="dark"|"light"` on `<html>`, so the
|
|
15
|
+
* attribute is observed.
|
|
16
|
+
* - The default "auto" sets nothing, and the theme follows the OS. So
|
|
17
|
+
* `prefers-color-scheme` is watched too — otherwise a diagram would not
|
|
18
|
+
* follow the system flipping to dark at sunset.
|
|
19
|
+
*/
|
|
20
|
+
export function useIsDarkMode() {
|
|
21
|
+
const [isDark, setIsDark] = useState(false);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const read = () => {
|
|
24
|
+
const explicit = document.documentElement.dataset.theme;
|
|
25
|
+
if (explicit === 'dark')
|
|
26
|
+
return true;
|
|
27
|
+
if (explicit === 'light')
|
|
28
|
+
return false;
|
|
29
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
30
|
+
};
|
|
31
|
+
setIsDark(read());
|
|
32
|
+
const observer = new MutationObserver(() => setIsDark(read()));
|
|
33
|
+
observer.observe(document.documentElement, {
|
|
34
|
+
attributes: true,
|
|
35
|
+
attributeFilter: ['data-theme'],
|
|
36
|
+
});
|
|
37
|
+
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
38
|
+
const onMedia = () => setIsDark(read());
|
|
39
|
+
media.addEventListener('change', onMedia);
|
|
40
|
+
return () => {
|
|
41
|
+
observer.disconnect();
|
|
42
|
+
media.removeEventListener('change', onMedia);
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
return isDark;
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@liminis/diagrams/playground` — a ready-made C4 editing surface.
|
|
3
|
+
*
|
|
4
|
+
* A React component with a source pane, a live draggable diagram, zoom, and an
|
|
5
|
+
* expand-to-lightbox affordance. This is the shell this package's own
|
|
6
|
+
* documentation uses; it lives here because five documentation sites were
|
|
7
|
+
* keeping identical copies of it, and they drifted the moment the renderer
|
|
8
|
+
* gained a feature.
|
|
9
|
+
*
|
|
10
|
+
* Requires `@liminis/diagrams/playground.css`, imported separately — bundlers
|
|
11
|
+
* differ too much about CSS-in-package for importing it from here to be safe.
|
|
12
|
+
*
|
|
13
|
+
* React and react-dom are optional peer dependencies, as they are for `./react`.
|
|
14
|
+
* Nothing in `./core` reaches this file.
|
|
15
|
+
*/
|
|
16
|
+
export { default as C4Playground } from './playground/C4Playground.js';
|
|
17
|
+
export type { C4PlaygroundProps } from './playground/C4Playground.js';
|
|
18
|
+
/**
|
|
19
|
+
* Tracks a host that follows the `data-theme="dark"|"light"` convention, falling
|
|
20
|
+
* back to `prefers-color-scheme`. Entirely optional: `C4Playground` takes
|
|
21
|
+
* `isDarkMode` as a prop, and a host with its own theming should pass its own
|
|
22
|
+
* answer rather than use this.
|
|
23
|
+
*/
|
|
24
|
+
export { useIsDarkMode } from './playground/useIsDarkMode.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@liminis/diagrams/playground` — a ready-made C4 editing surface.
|
|
3
|
+
*
|
|
4
|
+
* A React component with a source pane, a live draggable diagram, zoom, and an
|
|
5
|
+
* expand-to-lightbox affordance. This is the shell this package's own
|
|
6
|
+
* documentation uses; it lives here because five documentation sites were
|
|
7
|
+
* keeping identical copies of it, and they drifted the moment the renderer
|
|
8
|
+
* gained a feature.
|
|
9
|
+
*
|
|
10
|
+
* Requires `@liminis/diagrams/playground.css`, imported separately — bundlers
|
|
11
|
+
* differ too much about CSS-in-package for importing it from here to be safe.
|
|
12
|
+
*
|
|
13
|
+
* React and react-dom are optional peer dependencies, as they are for `./react`.
|
|
14
|
+
* Nothing in `./core` reaches this file.
|
|
15
|
+
*/
|
|
16
|
+
export { default as C4Playground } from './playground/C4Playground.js';
|
|
17
|
+
/**
|
|
18
|
+
* Tracks a host that follows the `data-theme="dark"|"light"` convention, falling
|
|
19
|
+
* back to `prefers-color-scheme`. Entirely optional: `C4Playground` takes
|
|
20
|
+
* `isDarkMode` as a prop, and a host with its own theming should pass its own
|
|
21
|
+
* answer rather than use this.
|
|
22
|
+
*/
|
|
23
|
+
export { useIsDarkMode } from './playground/useIsDarkMode.js';
|
|
@@ -25,6 +25,29 @@ export interface C4InteractiveRendererProps {
|
|
|
25
25
|
x: number;
|
|
26
26
|
y: number;
|
|
27
27
|
}>) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Scale factor for the rendered diagram. 1 is actual size; 2 draws it twice as
|
|
30
|
+
* large. Defaults to 1, so a host that does not care is unaffected.
|
|
31
|
+
*
|
|
32
|
+
* This scales the SVG's rendered `width`/`height` and leaves the `viewBox`
|
|
33
|
+
* alone, rather than applying a CSS transform. Three things follow, and they
|
|
34
|
+
* are the reason for doing it this way:
|
|
35
|
+
*
|
|
36
|
+
* - Dragging keeps working untouched. Screen coordinates are mapped through
|
|
37
|
+
* `getScreenCTM().inverse()`, and that matrix already carries the ratio
|
|
38
|
+
* between the viewBox and the rendered size — so a node still lands under
|
|
39
|
+
* the pointer at any zoom, with no arithmetic anywhere in the drag code.
|
|
40
|
+
* - The diagram genuinely occupies more space, so a scrolling container
|
|
41
|
+
* scrolls it. A CSS `transform` is painted after layout: the element still
|
|
42
|
+
* reports its unscaled size, and the overflow it appears to produce cannot
|
|
43
|
+
* be scrolled to.
|
|
44
|
+
* - Text stays sharp. It is re-rendered at the new scale rather than being a
|
|
45
|
+
* bitmap stretched over it.
|
|
46
|
+
*
|
|
47
|
+
* Controls are deliberately not included. What a zoom control should look like
|
|
48
|
+
* is the host's business — this package renders diagrams.
|
|
49
|
+
*/
|
|
50
|
+
zoom?: number;
|
|
28
51
|
}
|
|
29
52
|
/**
|
|
30
53
|
* Interactive C4 diagram renderer with drag support.
|
|
@@ -32,5 +55,5 @@ export interface C4InteractiveRendererProps {
|
|
|
32
55
|
* When isEditMode is true, nodes can be dragged to new positions.
|
|
33
56
|
* Edges and boundaries are recalculated in real-time during drag.
|
|
34
57
|
*/
|
|
35
|
-
export declare function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, }: C4InteractiveRendererProps): JSX.Element;
|
|
58
|
+
export declare function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, zoom, }: C4InteractiveRendererProps): JSX.Element;
|
|
36
59
|
export default C4InteractiveRenderer;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { C4RendererContent, computeLegendInfo } from './renderer.js';
|
|
3
|
+
import { C4RendererContent, computeLegendInfo, normaliseZoom } from './renderer.js';
|
|
4
4
|
import { layoutC4Diagram } from '../core/layout.js';
|
|
5
5
|
import { useC4DiagramDrag } from './hooks/useC4DiagramDrag.js';
|
|
6
6
|
/** Synthetic ID used to store legend position in manual positions map */
|
|
@@ -51,7 +51,7 @@ function collectDescendantIds(elements) {
|
|
|
51
51
|
* When isEditMode is true, nodes can be dragged to new positions.
|
|
52
52
|
* Edges and boundaries are recalculated in real-time during drag.
|
|
53
53
|
*/
|
|
54
|
-
export function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, }) {
|
|
54
|
+
export function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, zoom = 1, }) {
|
|
55
55
|
const svgRef = useRef(null);
|
|
56
56
|
// Local positions during drag (merged with persisted positions)
|
|
57
57
|
const [dragPositions, setDragPositions] = useState({});
|
|
@@ -222,12 +222,15 @@ export function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualP
|
|
|
222
222
|
return pos ?? null;
|
|
223
223
|
}, [effectivePositions]);
|
|
224
224
|
// Render with interactive wrappers
|
|
225
|
-
return (_jsx(C4InteractiveSvg, { layout: layout, isDarkMode: isDarkMode, isEditMode: isEditMode, draggedNodeId: draggedNodeId, svgRef: svgRef, onNodeMouseDown: startNodeDrag, legendInfo: legendInfo, legendPositionOverride: legendPositionOverride }));
|
|
225
|
+
return (_jsx(C4InteractiveSvg, { layout: layout, isDarkMode: isDarkMode, isEditMode: isEditMode, draggedNodeId: draggedNodeId, svgRef: svgRef, onNodeMouseDown: startNodeDrag, legendInfo: legendInfo, legendPositionOverride: legendPositionOverride, zoom: zoom }));
|
|
226
226
|
}
|
|
227
227
|
/**
|
|
228
228
|
* SVG wrapper that adds interactive overlays for drag handling.
|
|
229
229
|
*/
|
|
230
|
-
function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRef, onNodeMouseDown, legendInfo, legendPositionOverride, }) {
|
|
230
|
+
function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRef, onNodeMouseDown, legendInfo, legendPositionOverride, zoom, }) {
|
|
231
|
+
// Guarded here as well as in C4Renderer: both are public entry points, and a
|
|
232
|
+
// host reaching this one never passes through the other.
|
|
233
|
+
const scale = normaliseZoom(zoom);
|
|
231
234
|
// Get colors based on theme
|
|
232
235
|
const handleColor = isDarkMode ? '#a0a0a0' : '#505050';
|
|
233
236
|
// Create hit areas for each node (and legend if present)
|
|
@@ -276,7 +279,7 @@ function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRe
|
|
|
276
279
|
}
|
|
277
280
|
return { svgWidth: w, svgHeight: h };
|
|
278
281
|
}, [layout.width, layout.height, layout.viewBoxX, layout.viewBoxY, legendInfo, legendPositionOverride]);
|
|
279
|
-
return (_jsx("div", { style: { position: 'relative' }, children: _jsxs("svg", { ref: svgRef, width: svgWidth, height: svgHeight, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${svgWidth} ${svgHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: {
|
|
282
|
+
return (_jsx("div", { style: { position: 'relative' }, children: _jsxs("svg", { ref: svgRef, width: svgWidth * scale, height: svgHeight * scale, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${svgWidth} ${svgHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: {
|
|
280
283
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
281
284
|
cursor: isEditMode ? (draggedNodeId ? 'grabbing' : 'default') : 'default',
|
|
282
285
|
}, children: [_jsx(C4RendererContent, { layout: layout, isDarkMode: isDarkMode, legendPositionOverride: legendPositionOverride }), isEditMode && (_jsx("g", { className: "interactive-layer", children: hitAreas.map((area) => (_jsx("rect", { "data-node-id": area.id, x: area.x, y: area.y, width: area.width, height: area.height, fill: "transparent", stroke: "transparent", style: {
|
package/dist/react/renderer.d.ts
CHANGED
|
@@ -27,11 +27,33 @@ export declare function computeLegendInfo(layout: LayoutResult): {
|
|
|
27
27
|
width: number;
|
|
28
28
|
height: number;
|
|
29
29
|
} | null;
|
|
30
|
+
/**
|
|
31
|
+
* A zoom factor that cannot produce an invalid SVG.
|
|
32
|
+
*
|
|
33
|
+
* `width`/`height` are attributes on a public entry point, so whatever a host
|
|
34
|
+
* passes ends up in the DOM. `0` collapses the diagram, a negative number is
|
|
35
|
+
* invalid, and `NaN` — the likely one, arriving from an uninitialised state
|
|
36
|
+
* variable or a division — renders nothing at all with no error to explain it.
|
|
37
|
+
* Anything that is not a positive finite number falls back to actual size.
|
|
38
|
+
*
|
|
39
|
+
* The upper bound is a guard against a browser being asked to lay out a
|
|
40
|
+
* hundred-thousand-pixel SVG, not a considered maximum; the lower bound is the
|
|
41
|
+
* point past which a diagram is a smudge. A host wanting more can scale the
|
|
42
|
+
* container.
|
|
43
|
+
*/
|
|
44
|
+
export declare function normaliseZoom(zoom: number): number;
|
|
30
45
|
export interface C4RendererProps {
|
|
31
46
|
/** Layout result from the layout engine */
|
|
32
47
|
layout: LayoutResult;
|
|
33
48
|
/** Whether dark mode is enabled */
|
|
34
49
|
isDarkMode: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Scale factor for the rendered diagram. 1 is actual size. Scales the SVG's
|
|
52
|
+
* width/height and leaves the viewBox alone, so text is re-rendered at the new
|
|
53
|
+
* scale rather than stretched, and a scrolling container can actually scroll
|
|
54
|
+
* the result. Defaults to 1.
|
|
55
|
+
*/
|
|
56
|
+
zoom?: number;
|
|
35
57
|
}
|
|
36
58
|
/**
|
|
37
59
|
* C4 Diagram SVG Renderer.
|
|
@@ -39,7 +61,7 @@ export interface C4RendererProps {
|
|
|
39
61
|
* Pure component: takes layout + theme, returns SVG.
|
|
40
62
|
* Used directly in the editor and via renderToStaticMarkup for publishing.
|
|
41
63
|
*/
|
|
42
|
-
export declare function C4Renderer({ layout, isDarkMode }: C4RendererProps): JSX.Element;
|
|
64
|
+
export declare function C4Renderer({ layout, isDarkMode, zoom }: C4RendererProps): JSX.Element;
|
|
43
65
|
/**
|
|
44
66
|
* Renders C4 diagram content (nodes, edges, legend) as SVG group elements.
|
|
45
67
|
* Exported for use by C4InteractiveRenderer to embed diagram content
|
package/dist/react/renderer.js
CHANGED
|
@@ -523,13 +523,36 @@ function renderNode(node, colors, allNodes, parentMap) {
|
|
|
523
523
|
return _jsx(Container, { node: node, colors: colors, allNodes: allNodes, parentMap: parentMap }, node.id);
|
|
524
524
|
}
|
|
525
525
|
}
|
|
526
|
+
// =============================================================================
|
|
527
|
+
// MAIN COMPONENT
|
|
528
|
+
// =============================================================================
|
|
529
|
+
/**
|
|
530
|
+
* A zoom factor that cannot produce an invalid SVG.
|
|
531
|
+
*
|
|
532
|
+
* `width`/`height` are attributes on a public entry point, so whatever a host
|
|
533
|
+
* passes ends up in the DOM. `0` collapses the diagram, a negative number is
|
|
534
|
+
* invalid, and `NaN` — the likely one, arriving from an uninitialised state
|
|
535
|
+
* variable or a division — renders nothing at all with no error to explain it.
|
|
536
|
+
* Anything that is not a positive finite number falls back to actual size.
|
|
537
|
+
*
|
|
538
|
+
* The upper bound is a guard against a browser being asked to lay out a
|
|
539
|
+
* hundred-thousand-pixel SVG, not a considered maximum; the lower bound is the
|
|
540
|
+
* point past which a diagram is a smudge. A host wanting more can scale the
|
|
541
|
+
* container.
|
|
542
|
+
*/
|
|
543
|
+
export function normaliseZoom(zoom) {
|
|
544
|
+
if (!Number.isFinite(zoom) || zoom <= 0)
|
|
545
|
+
return 1;
|
|
546
|
+
return Math.min(Math.max(zoom, 0.05), 50);
|
|
547
|
+
}
|
|
526
548
|
/**
|
|
527
549
|
* C4 Diagram SVG Renderer.
|
|
528
550
|
*
|
|
529
551
|
* Pure component: takes layout + theme, returns SVG.
|
|
530
552
|
* Used directly in the editor and via renderToStaticMarkup for publishing.
|
|
531
553
|
*/
|
|
532
|
-
export function C4Renderer({ layout, isDarkMode }) {
|
|
554
|
+
export function C4Renderer({ layout, isDarkMode, zoom = 1 }) {
|
|
555
|
+
const scale = normaliseZoom(zoom);
|
|
533
556
|
const colors = getColors(isDarkMode);
|
|
534
557
|
const boundaryNodes = layout.nodes.filter((n) => n.element.type === 'system' &&
|
|
535
558
|
((n.children?.length ?? 0) > 0 || n.element.properties.style === 'boundary'));
|
|
@@ -546,7 +569,7 @@ export function C4Renderer({ layout, isDarkMode }) {
|
|
|
546
569
|
totalWidth = lp.totalWidth;
|
|
547
570
|
totalHeight = lp.totalHeight;
|
|
548
571
|
}
|
|
549
|
-
return (_jsxs("svg", { width: totalWidth, height: totalHeight, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${totalWidth} ${totalHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: { fontFamily: 'system-ui, -apple-system, sans-serif' }, children: [_jsx("g", { className: "boundaries-layer", children: boundaryNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "nodes-layer", children: regularNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "edges-layer", children: processedEdges.map((edge, i) => (_jsx(EdgeComponent, { edge: edge, colors: colors }, `${edge.source}-${edge.target}-${i}`))) }), legendPlacement && legendEntries.length > 0 && (_jsx(Legend, { entries: legendEntries, placement: legendPlacement, colors: colors }))] }));
|
|
572
|
+
return (_jsxs("svg", { width: totalWidth * scale, height: totalHeight * scale, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${totalWidth} ${totalHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: { fontFamily: 'system-ui, -apple-system, sans-serif' }, children: [_jsx("g", { className: "boundaries-layer", children: boundaryNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "nodes-layer", children: regularNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "edges-layer", children: processedEdges.map((edge, i) => (_jsx(EdgeComponent, { edge: edge, colors: colors }, `${edge.source}-${edge.target}-${i}`))) }), legendPlacement && legendEntries.length > 0 && (_jsx(Legend, { entries: legendEntries, placement: legendPlacement, colors: colors }))] }));
|
|
550
573
|
}
|
|
551
574
|
export function C4RendererContent({ layout, isDarkMode, legendPositionOverride }) {
|
|
552
575
|
const colors = getColors(isDarkMode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liminis/diagrams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "C4 architecture diagrams: parse C4-PlantUML, lay out with dagre, render to SVG",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"//repository": "Not cosmetic, and not optional. npm matches this URL against the GitHub Actions OIDC claim when publishing with --provenance; without it the registry rejects the publish outright (E422) after the release tag has already been cut. That is exactly how 0.1.0's first release attempt failed (#6). The `git+https://` scheme and the `.git` suffix are both part of the match \u2014 the SSH form does not work.",
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"types": "./dist/react.d.ts",
|
|
49
49
|
"default": "./dist/react.js"
|
|
50
50
|
},
|
|
51
|
+
"./playground": {
|
|
52
|
+
"types": "./dist/playground.d.ts",
|
|
53
|
+
"default": "./dist/playground.js"
|
|
54
|
+
},
|
|
55
|
+
"./playground.css": "./dist/playground/playground.css",
|
|
51
56
|
"./server": {
|
|
52
57
|
"types": "./dist/server.d.ts",
|
|
53
58
|
"default": "./dist/server.js"
|
|
@@ -61,7 +66,7 @@
|
|
|
61
66
|
"dist"
|
|
62
67
|
],
|
|
63
68
|
"scripts": {
|
|
64
|
-
"build": "pnpm run clean && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths",
|
|
69
|
+
"build": "pnpm run clean && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && node scripts/copy-assets.mjs",
|
|
65
70
|
"clean": "rm -rf dist",
|
|
66
71
|
"prepack": "pnpm run build",
|
|
67
72
|
"prepublishOnly": "node scripts/guard-publish.mjs",
|