@itsthecsguy/linework 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +11 -0
- package/README.md +164 -2
- package/assets/drawio/LICENSE +201 -0
- package/assets/drawio/VERSION +1 -0
- package/assets/drawio/favicon.ico +0 -0
- package/assets/drawio/images/1x1.png +0 -0
- package/assets/drawio/images/2x2.png +0 -0
- package/assets/drawio/images/3x3.png +0 -0
- package/assets/drawio/images/ajax-loader.gif +0 -0
- package/assets/drawio/images/android-chrome-512x512.png +0 -0
- package/assets/drawio/images/apple-touch-icon.png +0 -0
- package/assets/drawio/images/aui-wait.gif +0 -0
- package/assets/drawio/images/broken.png +0 -0
- package/assets/drawio/images/browserconfig.xml +9 -0
- package/assets/drawio/images/close.png +0 -0
- package/assets/drawio/images/cloud-connector-atlas.png +0 -0
- package/assets/drawio/images/collapsed.gif +0 -0
- package/assets/drawio/images/delete.png +0 -0
- package/assets/drawio/images/document-google-drive-icon-80.png +0 -0
- package/assets/drawio/images/draw-search.png +0 -0
- package/assets/drawio/images/drawio.svg +19 -0
- package/assets/drawio/images/drawlogo-color.svg +50 -0
- package/assets/drawio/images/drawlogo-gray.svg +18 -0
- package/assets/drawio/images/drawlogo-text-bottom.svg +75 -0
- package/assets/drawio/images/drawlogo.svg +18 -0
- package/assets/drawio/images/drawlogo128.png +0 -0
- package/assets/drawio/images/drawlogo144.png +0 -0
- package/assets/drawio/images/drawlogo256.png +0 -0
- package/assets/drawio/images/drawlogo48-gray.png +0 -0
- package/assets/drawio/images/drawlogo48.png +0 -0
- package/assets/drawio/images/drawlogo80.png +0 -0
- package/assets/drawio/images/droptarget.png +0 -0
- package/assets/drawio/images/edit.gif +0 -0
- package/assets/drawio/images/edit16.png +0 -0
- package/assets/drawio/images/edit32.png +0 -0
- package/assets/drawio/images/edit80.png +0 -0
- package/assets/drawio/images/embed-icon.png +0 -0
- package/assets/drawio/images/expanded.gif +0 -0
- package/assets/drawio/images/facebook.png +0 -0
- package/assets/drawio/images/favicon-16x16.png +0 -0
- package/assets/drawio/images/favicon-32x32.png +0 -0
- package/assets/drawio/images/file.png +0 -0
- package/assets/drawio/images/folder.png +0 -0
- package/assets/drawio/images/gadget-screenshot.png +0 -0
- package/assets/drawio/images/gadget-thumb.png +0 -0
- package/assets/drawio/images/github-logo-white.svg +6 -0
- package/assets/drawio/images/github-logo.svg +17 -0
- package/assets/drawio/images/gitlab-logo-white.svg +1 -0
- package/assets/drawio/images/gitlab-logo.svg +1 -0
- package/assets/drawio/images/glyphicons_blogger.png +0 -0
- package/assets/drawio/images/glyphicons_facebook.png +0 -0
- package/assets/drawio/images/glyphicons_github.png +0 -0
- package/assets/drawio/images/glyphicons_google.png +0 -0
- package/assets/drawio/images/glyphicons_star.png +0 -0
- package/assets/drawio/images/glyphicons_twitter.png +0 -0
- package/assets/drawio/images/glyphicons_youtube.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.svg +121 -0
- package/assets/drawio/images/google-drive-logo-white.svg +9 -0
- package/assets/drawio/images/google-drive-logo.svg +17 -0
- package/assets/drawio/images/google-share.png +0 -0
- package/assets/drawio/images/icon-192-maskable.png +0 -0
- package/assets/drawio/images/icon-192.png +0 -0
- package/assets/drawio/images/icon-512-maskable.png +0 -0
- package/assets/drawio/images/icon-512.png +0 -0
- package/assets/drawio/images/icon-search.svg +21 -0
- package/assets/drawio/images/img-hi-res.png +0 -0
- package/assets/drawio/images/img-lo-res.png +0 -0
- package/assets/drawio/images/list.svg +16 -0
- package/assets/drawio/images/locked.png +0 -0
- package/assets/drawio/images/log.png +0 -0
- package/assets/drawio/images/logo-confluence.png +0 -0
- package/assets/drawio/images/logo-flat-small.png +0 -0
- package/assets/drawio/images/logo-flat.png +0 -0
- package/assets/drawio/images/logo-jira.png +0 -0
- package/assets/drawio/images/logo-small.gif +0 -0
- package/assets/drawio/images/logo-white.png +0 -0
- package/assets/drawio/images/logo.png +0 -0
- package/assets/drawio/images/manifest.json +25 -0
- package/assets/drawio/images/move.png +0 -0
- package/assets/drawio/images/notion-logo-white.svg +1 -0
- package/assets/drawio/images/notion-logo.svg +1 -0
- package/assets/drawio/images/onedrive-connector-atlas.png +0 -0
- package/assets/drawio/images/onedrive-connector-atlas.svg +248 -0
- package/assets/drawio/images/onedrive-logo-white.svg +29 -0
- package/assets/drawio/images/onedrive-logo.svg +29 -0
- package/assets/drawio/images/osa_database.png +0 -0
- package/assets/drawio/images/osa_drive-harddisk.png +0 -0
- package/assets/drawio/images/plant-uml-atlas.png +0 -0
- package/assets/drawio/images/samepage-icon-color.svg +37 -0
- package/assets/drawio/images/save.png +0 -0
- package/assets/drawio/images/sidebar-android.png +0 -0
- package/assets/drawio/images/sidebar-archimate.png +0 -0
- package/assets/drawio/images/sidebar-arrows.png +0 -0
- package/assets/drawio/images/sidebar-arrows2.png +0 -0
- package/assets/drawio/images/sidebar-basic.png +0 -0
- package/assets/drawio/images/sidebar-c4.png +0 -0
- package/assets/drawio/images/sidebar-cabinets.png +0 -0
- package/assets/drawio/images/sidebar-cumulus.png +0 -0
- package/assets/drawio/images/sidebar-dfd.png +0 -0
- package/assets/drawio/images/sidebar-dynamics365.png +0 -0
- package/assets/drawio/images/sidebar-eip.png +0 -0
- package/assets/drawio/images/sidebar-er.png +0 -0
- package/assets/drawio/images/sidebar-floorplans.png +0 -0
- package/assets/drawio/images/sidebar-flowchart.png +0 -0
- package/assets/drawio/images/sidebar-gcp3.png +0 -0
- package/assets/drawio/images/sidebar-general.png +0 -0
- package/assets/drawio/images/sidebar-kubernetes.png +0 -0
- package/assets/drawio/images/sidebar-leanmapping.png +0 -0
- package/assets/drawio/images/sidebar-mockups.png +0 -0
- package/assets/drawio/images/sidebar-network.png +0 -0
- package/assets/drawio/images/sidebar-network2.png +0 -0
- package/assets/drawio/images/sidebar-openstack.png +0 -0
- package/assets/drawio/images/sidebar-sitemap.png +0 -0
- package/assets/drawio/images/sidebar-threatmodeling.png +0 -0
- package/assets/drawio/images/sidebar-uml.png +0 -0
- package/assets/drawio/images/sketch-logo128.png +0 -0
- package/assets/drawio/images/spin.gif +0 -0
- package/assets/drawio/images/sprites.png +0 -0
- package/assets/drawio/images/stop-flat-icon-80.png +0 -0
- package/assets/drawio/images/submenu.gif +0 -0
- package/assets/drawio/images/touch-submenu.png +0 -0
- package/assets/drawio/images/transparent.gif +0 -0
- package/assets/drawio/images/trello-logo-white-orange.svg +6 -0
- package/assets/drawio/images/trello-logo-white.svg +6 -0
- package/assets/drawio/images/trello-logo.svg +1 -0
- package/assets/drawio/images/tweet.png +0 -0
- package/assets/drawio/images/update16.png +0 -0
- package/assets/drawio/images/update32.png +0 -0
- package/assets/drawio/images/update80.png +0 -0
- package/assets/drawio/images/youtube.png +0 -0
- package/assets/drawio/img/clipart/Battery_0_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_100_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_50_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_75_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_allstates_128x128.png +0 -0
- package/assets/drawio/img/clipart/Bluetooth_128x128.png +0 -0
- package/assets/drawio/img/clipart/Earth_globe_128x128.png +0 -0
- package/assets/drawio/img/clipart/Empty_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Full_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Gear_128x128.png +0 -0
- package/assets/drawio/img/clipart/Keys_128x128.png +0 -0
- package/assets/drawio/img/clipart/Lock_128x128.png +0 -0
- package/assets/drawio/img/clipart/Mouse_Pointer_128x128.png +0 -0
- package/assets/drawio/img/clipart/Plug_128x128.png +0 -0
- package/assets/drawio/img/clipart/Ships_Wheel_128x128.png +0 -0
- package/assets/drawio/img/clipart/Star_128x128.png +0 -0
- package/assets/drawio/img/clipart/Tire_128x128.png +0 -0
- package/assets/drawio/index.html +92 -0
- package/assets/drawio/js/PostConfig.js +7 -0
- package/assets/drawio/js/PreConfig.js +13 -0
- package/assets/drawio/js/app.min.js +18466 -0
- package/assets/drawio/js/bootstrap.js +428 -0
- package/assets/drawio/js/diagramly/LibavoidRouting.js +4459 -0
- package/assets/drawio/js/export-init.js +21 -0
- package/assets/drawio/js/extensions.min.js +10478 -0
- package/assets/drawio/js/libavoid-js/LICENSE +505 -0
- package/assets/drawio/js/libavoid-js/libavoid-routing.js +1633 -0
- package/assets/drawio/js/libavoid-js/libavoid.min.js +59 -0
- package/assets/drawio/js/main.js +22 -0
- package/assets/drawio/js/rough/rough.min.js +1 -0
- package/assets/drawio/js/shapes-14-6-5.min.js +3457 -0
- package/assets/drawio/js/stencils.min.js +447 -0
- package/assets/drawio/mxgraph/css/common.css +147 -0
- package/assets/drawio/resources/dia.txt +2901 -0
- package/assets/drawio/styles/dark-default.xml +109 -0
- package/assets/drawio/styles/default-old.xml +109 -0
- package/assets/drawio/styles/default.xml +203 -0
- package/assets/drawio/styles/fonts/ArchitectsDaughter-Regular.ttf +0 -0
- package/assets/drawio/styles/grapheditor.css +4501 -0
- package/assets/drawio/styles/high-contrast.css +128 -0
- package/assets/drawio/styles/thumb_horz.png +0 -0
- package/assets/drawio/styles/thumb_vertical.png +0 -0
- package/assets/fonts/ibm-plex-mono-LICENSE.txt +93 -0
- package/assets/fonts/ibm-plex-sans-LICENSE.txt +93 -0
- package/assets/fonts/kalam-400.woff2 +0 -0
- package/assets/fonts/kalam-700.woff2 +0 -0
- package/assets/fonts/kalam-LICENSE.txt +93 -0
- package/assets/fonts/plex-mono-400.woff2 +0 -0
- package/assets/fonts/plex-mono-600.woff2 +0 -0
- package/assets/fonts/plex-sans-400.woff2 +0 -0
- package/assets/fonts/plex-sans-600.woff2 +0 -0
- package/dist/api.js +181 -0
- package/dist/cli.js +262 -0
- package/dist/drawio/icons.js +121 -0
- package/dist/drawio/merge.js +309 -0
- package/dist/drawio/read.js +180 -0
- package/dist/drawio/show.js +501 -0
- package/dist/drawio/theme.js +425 -0
- package/dist/drawio/write.js +300 -0
- package/dist/edit/server.js +212 -0
- package/dist/git.js +46 -0
- package/dist/import/mermaid.js +569 -0
- package/dist/layout/layout.js +212 -0
- package/dist/layout/sequence.js +333 -0
- package/dist/layout/text.js +73 -0
- package/dist/lint/check.js +184 -0
- package/dist/lint/fixlabels.js +128 -0
- package/dist/mcp/server.js +239 -0
- package/dist/publish.js +121 -0
- package/dist/render/browser.js +210 -0
- package/dist/render/fonts.js +66 -0
- package/dist/report.js +36 -0
- package/dist/spec/model.js +53 -0
- package/dist/spec/parse.js +497 -0
- package/package.json +71 -4
- package/skills/linework/SKILL.md +214 -0
- package/skills/linework/examples/billing-er.drawio.svg +4 -0
- package/skills/linework/examples/billing-er.yaml +31 -0
- package/skills/linework/examples/checkout.drawio.svg +4 -0
- package/skills/linework/examples/checkout.yaml +28 -0
- package/skills/linework/examples/pay-sequence.drawio.svg +4 -0
- package/skills/linework/examples/pay-sequence.yaml +28 -0
- package/skills/linework/examples/payment-state.drawio.svg +4 -0
- package/skills/linework/examples/payment-state.yaml +22 -0
- package/skills/linework/examples/refund-flow.drawio.svg +4 -0
- package/skills/linework/examples/refund-flow.yaml +29 -0
- package/skills/linework/examples/serverless-aws.drawio.svg +4 -0
- package/skills/linework/examples/serverless-aws.yaml +23 -0
- package/skills/linework/references/google-docs.md +46 -0
- package/skills/linework/references/spec.md +312 -0
- package/skills/linework/references/style-guide.md +53 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { kindDef } from '../spec/model.js';
|
|
2
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
3
|
+
import { ICON_PAD, ICON_SIZE, resolveIcon } from './icons.js';
|
|
4
|
+
import { ER_HEAD, ER_ROW, edgeStyle, erEdgeStyle, fieldLabel, fieldStyle, groupLabel, groupStyle, nodeLabel, nodeStyle, paintFor, palette } from './theme.js';
|
|
5
|
+
export { ER_HEAD, ER_ROW };
|
|
6
|
+
export const FORMAT_VERSION = '1';
|
|
7
|
+
export const xmlEsc = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, ' ');
|
|
8
|
+
const r = (v) => Math.round(v);
|
|
9
|
+
export const autoAttr = (b) => `${r(b.x)},${r(b.y)},${r(b.w)},${r(b.h)}`;
|
|
10
|
+
export function edgeCellId(edgeId) {
|
|
11
|
+
return 'e_' + edgeId.replace('->', '__').replace('#', '_');
|
|
12
|
+
}
|
|
13
|
+
export function diagramMeta(d, extra = {}) {
|
|
14
|
+
return {
|
|
15
|
+
aiviz_version: FORMAT_VERSION,
|
|
16
|
+
aiviz_type: d.type,
|
|
17
|
+
aiviz_direction: d.direction,
|
|
18
|
+
...(d.theme !== 'clean' ? { aiviz_theme: d.theme } : {}),
|
|
19
|
+
...(d.title ? { aiviz_title: d.title } : {}),
|
|
20
|
+
...extra,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function nodeAttrs(d, n) {
|
|
24
|
+
const shape = kindDef(d.type, n.kind).shape;
|
|
25
|
+
const label = shape === 'start' || shape === 'end' ? '' : shape === 'table' ? n.label : nodeLabel(n.label, n.tech, { theme: d.theme, type: d.type, kind: n.kind, color: n.color, highlight: n.highlight });
|
|
26
|
+
return {
|
|
27
|
+
label,
|
|
28
|
+
aiviz_kind: n.kind,
|
|
29
|
+
aiviz_label: n.label,
|
|
30
|
+
...(n.tech ? { aiviz_tech: n.tech } : {}),
|
|
31
|
+
...(n.icon ? { aiviz_icon: n.icon } : {}),
|
|
32
|
+
...lookAttrs(n),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** color/highlight as cell attributes, so `show` gives them back. */
|
|
36
|
+
export function lookAttrs(l) {
|
|
37
|
+
return { ...(l.color ? { aiviz_color: l.color } : {}), ...(l.highlight ? { aiviz_highlight: '1' } : {}) };
|
|
38
|
+
}
|
|
39
|
+
export function lookOf(attrs) {
|
|
40
|
+
const out = {};
|
|
41
|
+
if (attrs.aiviz_color)
|
|
42
|
+
out.color = attrs.aiviz_color;
|
|
43
|
+
if (attrs.aiviz_highlight === '1')
|
|
44
|
+
out.highlight = true;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Field rows of an ER entity, as children stacked under its header. */
|
|
48
|
+
export function fieldRows(d, n, width) {
|
|
49
|
+
return (n.fields ?? []).map((f, i) => ({
|
|
50
|
+
id: `${n.id}__${f.name}`,
|
|
51
|
+
parent: n.id,
|
|
52
|
+
attrs: {
|
|
53
|
+
label: fieldLabel(f.name, f.type, f.flags, d.theme),
|
|
54
|
+
aiviz_field: f.name,
|
|
55
|
+
...(f.type ? { aiviz_ftype: f.type } : {}),
|
|
56
|
+
...(f.flags.length ? { aiviz_flags: f.flags.join(' ') } : {}),
|
|
57
|
+
},
|
|
58
|
+
style: fieldStyle(d.theme),
|
|
59
|
+
box: { x: 0, y: ER_HEAD + i * ER_ROW, w: width, h: ER_ROW },
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
/** Children a node carries: ER field rows, or a cloud icon. */
|
|
63
|
+
export function nodeChildren(d, n, box) {
|
|
64
|
+
const out = fieldRows(d, n, box.w);
|
|
65
|
+
if (n.icon) {
|
|
66
|
+
const paint = paintFor(d.type, n.kind, d.theme, n);
|
|
67
|
+
const fill = /^#[0-9a-f]{6}$/i.test(paint.fill) ? paint.fill : palette(d.theme).canvas;
|
|
68
|
+
const r = resolveIcon(n.icon, { fill, ink: paint.text });
|
|
69
|
+
if (!('error' in r))
|
|
70
|
+
out.push({
|
|
71
|
+
id: `${n.id}__icon`,
|
|
72
|
+
parent: n.id,
|
|
73
|
+
attrs: { label: '', aiviz_type: 'icon' },
|
|
74
|
+
style: r.style,
|
|
75
|
+
box: { x: (ICON_PAD - ICON_SIZE) / 2 + 2, y: Math.round((box.h - ICON_SIZE) / 2), w: ICON_SIZE, h: ICON_SIZE },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** Node style, with room on the left when it has an icon. */
|
|
81
|
+
export function styleFor(d, n) {
|
|
82
|
+
const st = nodeStyle(d.type, n.kind, d.theme, n);
|
|
83
|
+
return n.icon ? `${st}align=left;spacingLeft=${ICON_PAD};` : st;
|
|
84
|
+
}
|
|
85
|
+
/** An edge label inside a filled group gets the group's fill behind it, not the canvas. */
|
|
86
|
+
function labelBg(d, l, label) {
|
|
87
|
+
if (!label)
|
|
88
|
+
return undefined;
|
|
89
|
+
const cx = label.x + label.w / 2, cy = label.y + label.h / 2;
|
|
90
|
+
let best;
|
|
91
|
+
for (const g of d.groups) {
|
|
92
|
+
const b = l.groups.get(g.id);
|
|
93
|
+
if (!b || cx < b.x || cx > b.x + b.w || cy < b.y || cy > b.y + b.h)
|
|
94
|
+
continue;
|
|
95
|
+
const fill = /fillColor=([^;]+)/.exec(groupStyleFor(d, g))?.[1];
|
|
96
|
+
if (fill && fill !== 'none' && (!best || b.w * b.h < best.area))
|
|
97
|
+
best = { area: b.w * b.h, fill };
|
|
98
|
+
}
|
|
99
|
+
return best?.fill;
|
|
100
|
+
}
|
|
101
|
+
export function edgeStyleFor(d, e, bg) {
|
|
102
|
+
return d.type === 'er' && e.card ? erEdgeStyle(e.card, d.theme, e) : edgeStyle(e.style, d.theme, e, bg);
|
|
103
|
+
}
|
|
104
|
+
export function edgeAttrs(e) {
|
|
105
|
+
return { label: e.label ?? '', aiviz_edge: e.id, ...lookAttrs(e) };
|
|
106
|
+
}
|
|
107
|
+
/** How deep a group sits: 0 at the top, 1 inside another group, and so on. */
|
|
108
|
+
export function groupDepth(d, g) {
|
|
109
|
+
let n = 0;
|
|
110
|
+
for (let p = g.parent; p && n < 20; p = d.groups.find((x) => x.id === p)?.parent)
|
|
111
|
+
n++;
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
114
|
+
export const groupStyleFor = (d, g) => groupStyle(d.theme, g, groupDepth(d, g));
|
|
115
|
+
export function groupAttrs(g, theme = 'clean') {
|
|
116
|
+
return { label: groupLabel(g.label, theme), aiviz_type: 'group', aiviz_label: g.label, ...lookAttrs(g) };
|
|
117
|
+
}
|
|
118
|
+
/** Exit/entry constraints so draw.io leaves and enters nodes where ELK did. */
|
|
119
|
+
export function portStyle(points, s, t) {
|
|
120
|
+
const start = points[0], end = points[points.length - 1];
|
|
121
|
+
const c01 = (v) => Math.min(1, Math.max(0, Math.round(v * 1000) / 1000));
|
|
122
|
+
return (`exitX=${c01((start.x - s.x) / s.w)};exitY=${c01((start.y - s.y) / s.h)};exitDx=0;exitDy=0;` +
|
|
123
|
+
`entryX=${c01((end.x - t.x) / t.w)};entryY=${c01((end.y - t.y) / t.h)};entryDx=0;entryDy=0;`);
|
|
124
|
+
}
|
|
125
|
+
export function sceneFromLayout(d, l) {
|
|
126
|
+
const parentOf = new Map();
|
|
127
|
+
for (const g of d.groups)
|
|
128
|
+
parentOf.set(g.id, g.parent);
|
|
129
|
+
for (const n of d.nodes)
|
|
130
|
+
parentOf.set(n.id, n.parent);
|
|
131
|
+
const abs = (id) => l.nodes.get(id) ?? l.groups.get(id);
|
|
132
|
+
const rel = (id) => {
|
|
133
|
+
const a = abs(id), p = parentOf.get(id);
|
|
134
|
+
if (!p)
|
|
135
|
+
return a;
|
|
136
|
+
const pb = abs(p);
|
|
137
|
+
return { ...a, x: a.x - pb.x, y: a.y - pb.y };
|
|
138
|
+
};
|
|
139
|
+
const vertices = [];
|
|
140
|
+
for (const g of d.groups) {
|
|
141
|
+
const box = rel(g.id);
|
|
142
|
+
vertices.push({ id: g.id, parent: g.parent ?? '1', attrs: { ...groupAttrs(g, d.theme), aiviz_auto: autoAttr(box) }, style: groupStyleFor(d, g), box });
|
|
143
|
+
}
|
|
144
|
+
for (const n of d.nodes) {
|
|
145
|
+
const box = rel(n.id);
|
|
146
|
+
vertices.push({ id: n.id, parent: n.parent ?? '1', attrs: { ...nodeAttrs(d, n), aiviz_auto: autoAttr(box) }, style: styleFor(d, n), box });
|
|
147
|
+
vertices.push(...nodeChildren(d, n, box));
|
|
148
|
+
}
|
|
149
|
+
const edges = d.edges.map((e) => {
|
|
150
|
+
const le = l.edges.get(e.id);
|
|
151
|
+
const base = { id: edgeCellId(e.id), parent: '1', attrs: edgeAttrs(e), style: edgeStyleFor(d, e, labelBg(d, l, le?.label)), source: e.from, target: e.to };
|
|
152
|
+
if (!le)
|
|
153
|
+
return base;
|
|
154
|
+
return {
|
|
155
|
+
...base,
|
|
156
|
+
style: base.style + portStyle(le.points, abs(e.from), abs(e.to)),
|
|
157
|
+
points: le.points.slice(1, -1),
|
|
158
|
+
labelX: le.label && e.label ? (labelSpot(le.points, le.label) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
return { name: d.title ?? 'Diagram', background: palette(d.theme).canvas, meta: diagramMeta(d), vertices, edges, raw: [] };
|
|
162
|
+
}
|
|
163
|
+
const attrString = (attrs) => Object.entries(attrs)
|
|
164
|
+
.filter(([k]) => k !== 'id')
|
|
165
|
+
.map(([k, v]) => ` ${k}="${xmlEsc(v)}"`)
|
|
166
|
+
.join('');
|
|
167
|
+
export function serialize(scene) {
|
|
168
|
+
// Parents before children.
|
|
169
|
+
const parentOf = new Map(scene.vertices.map((v) => [v.id, v.parent]));
|
|
170
|
+
const depth = (id) => {
|
|
171
|
+
const p = parentOf.get(id);
|
|
172
|
+
return p && p !== '1' ? 1 + depth(p) : 0;
|
|
173
|
+
};
|
|
174
|
+
// Containers first (outermost first), then everything else in its original order,
|
|
175
|
+
// which show() reads back and ELK uses as a placement hint.
|
|
176
|
+
const isParent = new Set(scene.vertices.map((v) => v.parent));
|
|
177
|
+
const containers = scene.vertices.filter((v) => isParent.has(v.id) || v.attrs.aiviz_type === 'group').sort((a, b) => depth(a.id) - depth(b.id));
|
|
178
|
+
const vertices = [...containers, ...scene.vertices.filter((v) => !containers.includes(v))];
|
|
179
|
+
const cells = [];
|
|
180
|
+
for (const v of vertices) {
|
|
181
|
+
const b = v.box;
|
|
182
|
+
cells.push(`<object id="${xmlEsc(v.id)}"${attrString({ label: '', ...v.attrs })}>` +
|
|
183
|
+
`<mxCell style="${xmlEsc(v.style)}" vertex="1" parent="${xmlEsc(v.parent)}">` +
|
|
184
|
+
`<mxGeometry x="${r(b.x)}" y="${r(b.y)}" width="${r(b.w)}" height="${r(b.h)}" as="geometry"/></mxCell></object>`);
|
|
185
|
+
}
|
|
186
|
+
cells.push(...scene.raw);
|
|
187
|
+
for (const e of scene.edges) {
|
|
188
|
+
const geom = e.geomRaw ??
|
|
189
|
+
`<mxGeometry ${e.labelX ? `x="${e.labelX.toFixed(3)}" ` : ''}relative="1" as="geometry">` +
|
|
190
|
+
(e.points?.length ? `<Array as="points">${e.points.map((p) => `<mxPoint x="${r(p.x)}" y="${r(p.y)}"/>`).join('')}</Array>` : '') +
|
|
191
|
+
`</mxGeometry>`;
|
|
192
|
+
cells.push(`<object id="${xmlEsc(e.id)}"${attrString({ label: '', ...e.attrs })}>` +
|
|
193
|
+
`<mxCell style="${xmlEsc(e.style)}" edge="1" parent="${xmlEsc(e.parent)}"` +
|
|
194
|
+
(e.source ? ` source="${xmlEsc(e.source)}"` : '') +
|
|
195
|
+
(e.target ? ` target="${xmlEsc(e.target)}"` : '') +
|
|
196
|
+
`>${geom}</mxCell></object>`);
|
|
197
|
+
}
|
|
198
|
+
return (`<mxfile host="aiviz" agent="aiviz"><diagram id="aiviz" name="${xmlEsc(scene.name)}">` +
|
|
199
|
+
`<mxGraphModel grid="1" gridSize="8" guides="1" tooltips="1" connect="1" arrows="1" page="0" background="${scene.background ?? '#FFFFFF'}" math="0" shadow="0">` +
|
|
200
|
+
`<root><object id="0"${attrString({ label: '', ...scene.meta })}><mxCell/></object><mxCell id="1" parent="0"/>${cells.join('')}</root></mxGraphModel></diagram></mxfile>`);
|
|
201
|
+
}
|
|
202
|
+
/** Fresh build: spec + layout -> XML. */
|
|
203
|
+
export function toMxfile(d, l) {
|
|
204
|
+
return serialize(sceneFromLayout(d, l));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Where a label reads best: centred on the edge's longest straight run, clear of
|
|
208
|
+
* the boxes at either end and of the bends. ELK puts labels right next to the
|
|
209
|
+
* source node, which reads as a caption of that node. Returns draw.io's -1..1
|
|
210
|
+
* fraction, or undefined when no run is long enough.
|
|
211
|
+
*/
|
|
212
|
+
function labelSpot(pts, label) {
|
|
213
|
+
let total = 0;
|
|
214
|
+
let best;
|
|
215
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
216
|
+
const a = pts[i], b = pts[i + 1];
|
|
217
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
218
|
+
const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
|
|
219
|
+
const room = len - (vertical ? label.h : label.w) - 20;
|
|
220
|
+
if (room >= 0 && (!best || room > best.room))
|
|
221
|
+
best = { room, at: total + len / 2 };
|
|
222
|
+
total += len;
|
|
223
|
+
}
|
|
224
|
+
return best && total ? (best.at / total) * 2 - 1 : undefined;
|
|
225
|
+
}
|
|
226
|
+
// draw.io positions edge labels by fraction along the path, -1..1.
|
|
227
|
+
// Project ELK's label centre onto the route and convert.
|
|
228
|
+
function labelFraction(pts, c) {
|
|
229
|
+
let total = 0;
|
|
230
|
+
let best = { d: Infinity, at: 0 };
|
|
231
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
232
|
+
const a = pts[i], b = pts[i + 1];
|
|
233
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
234
|
+
const t = len ? Math.max(0, Math.min(1, ((c.x - a.x) * (b.x - a.x) + (c.y - a.y) * (b.y - a.y)) / (len * len))) : 0;
|
|
235
|
+
const d = Math.hypot(c.x - (a.x + t * (b.x - a.x)), c.y - (a.y + t * (b.y - a.y)));
|
|
236
|
+
if (d < best.d)
|
|
237
|
+
best = { d, at: total + t * len };
|
|
238
|
+
total += len;
|
|
239
|
+
}
|
|
240
|
+
return total ? (best.at / total) * 2 - 1 : 0;
|
|
241
|
+
}
|
|
242
|
+
export const routeAttr = (pts) => pts.map((p) => `${r(p.x)},${r(p.y)}`).join(' ');
|
|
243
|
+
/**
|
|
244
|
+
* Record each linework edge's route as aiviz_auto, so a later merge can tell a route
|
|
245
|
+
* nobody touched (safe to reroute) from one a person adjusted (keep).
|
|
246
|
+
*/
|
|
247
|
+
export function stampEdgeRoutes(xml) {
|
|
248
|
+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
|
249
|
+
const objects = doc.getElementsByTagName('object');
|
|
250
|
+
for (let i = 0; i < objects.length; i++) {
|
|
251
|
+
const o = objects[i];
|
|
252
|
+
if (!o.getAttribute('aiviz_edge'))
|
|
253
|
+
continue;
|
|
254
|
+
const geom = o.getElementsByTagName('mxGeometry')[0];
|
|
255
|
+
const pts = [];
|
|
256
|
+
const arr = geom?.getElementsByTagName('Array')[0];
|
|
257
|
+
if (arr) {
|
|
258
|
+
const ps = arr.getElementsByTagName('mxPoint');
|
|
259
|
+
for (let j = 0; j < ps.length; j++)
|
|
260
|
+
pts.push({ x: +(ps[j].getAttribute('x') ?? 0), y: +(ps[j].getAttribute('y') ?? 0) });
|
|
261
|
+
}
|
|
262
|
+
o.setAttribute('aiviz_auto', routeAttr(pts));
|
|
263
|
+
}
|
|
264
|
+
return new XMLSerializer().serializeToString(doc);
|
|
265
|
+
}
|
|
266
|
+
function cellById(doc, id) {
|
|
267
|
+
for (const tag of ['object', 'UserObject', 'mxCell']) {
|
|
268
|
+
const els = doc.getElementsByTagName(tag);
|
|
269
|
+
for (let i = 0; i < els.length; i++) {
|
|
270
|
+
const el = els[i];
|
|
271
|
+
if (el.getAttribute('id') !== id)
|
|
272
|
+
continue;
|
|
273
|
+
if (tag === 'mxCell')
|
|
274
|
+
return el;
|
|
275
|
+
const inner = el.getElementsByTagName('mxCell')[0];
|
|
276
|
+
if (inner)
|
|
277
|
+
return inner;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Copy the style and geometry of the given edges from a model the browser rerouted
|
|
284
|
+
* back into our own XML. Only those edges change; everything else stays byte-stable.
|
|
285
|
+
*/
|
|
286
|
+
export function patchEdges(ours, routedModel, ids) {
|
|
287
|
+
const doc = new DOMParser().parseFromString(ours, 'text/xml');
|
|
288
|
+
const routed = new DOMParser().parseFromString(routedModel, 'text/xml');
|
|
289
|
+
for (const id of ids) {
|
|
290
|
+
const src = cellById(routed, id), dst = cellById(doc, id);
|
|
291
|
+
if (!src || !dst)
|
|
292
|
+
continue;
|
|
293
|
+
dst.setAttribute('style', src.getAttribute('style') ?? '');
|
|
294
|
+
const sg = src.getElementsByTagName('mxGeometry')[0];
|
|
295
|
+
const dg = dst.getElementsByTagName('mxGeometry')[0];
|
|
296
|
+
if (sg && dg)
|
|
297
|
+
dst.replaceChild(doc.importNode(sg, true), dg);
|
|
298
|
+
}
|
|
299
|
+
return new XMLSerializer().serializeToString(doc);
|
|
300
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// `linework edit`: open a diagram in the bundled draw.io editor, in the user's own
|
|
2
|
+
// browser, and write saves back to the same file. Uses draw.io's JSON embed
|
|
3
|
+
// protocol, the one the VS Code extension uses, so saved files are what
|
|
4
|
+
// draw.io itself writes. Everything is served from 127.0.0.1; nothing goes online.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { extractMxfile } from '../drawio/read.js';
|
|
11
|
+
import { assetsDir } from '../render/browser.js';
|
|
12
|
+
import { FACES, embedFonts, fontFaceCss, fontsDir } from '../render/fonts.js';
|
|
13
|
+
const TYPES = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8',
|
|
15
|
+
'.js': 'text/javascript',
|
|
16
|
+
'.css': 'text/css',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.gif': 'image/gif',
|
|
20
|
+
'.ico': 'image/x-icon',
|
|
21
|
+
'.json': 'application/json',
|
|
22
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
23
|
+
'.xml': 'text/xml',
|
|
24
|
+
};
|
|
25
|
+
const EMBED_PARAMS = 'embed=1&proto=json&spin=1&libraries=1&noSaveBtn=0&noExitBtn=0&offline=1&pwa=0&stealth=1&lang=en&dev=0';
|
|
26
|
+
function hostPage(name, xml, mtime, token, format) {
|
|
27
|
+
// JSON inside a <script>: escape "<" so a label containing "</script>" can't end the tag.
|
|
28
|
+
const data = JSON.stringify({ name, xml, mtime, format }).replace(/</g, '\\u003c');
|
|
29
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${name.replace(/[<&]/g, '')} - linework edit</title>
|
|
30
|
+
<style>html,body{margin:0;height:100%;overflow:hidden;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
|
|
31
|
+
iframe{border:0;width:100%;height:100%} #msg{display:none;padding:48px;color:#334155}</style></head>
|
|
32
|
+
<body><iframe id="f" src="/app/index.html?${EMBED_PARAMS}"></iframe><div id="msg"></div>
|
|
33
|
+
<script>
|
|
34
|
+
const D = ${data};
|
|
35
|
+
const base = '/${token}';
|
|
36
|
+
const f = document.getElementById('f');
|
|
37
|
+
const send = (m) => f.contentWindow.postMessage(JSON.stringify(m), '*');
|
|
38
|
+
let pending = null; // the save waiting for its export
|
|
39
|
+
async function post(p, body) {
|
|
40
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) });
|
|
41
|
+
return { ok: r.ok, body: await r.json().catch(() => ({})) };
|
|
42
|
+
}
|
|
43
|
+
function finish(text) {
|
|
44
|
+
f.remove();
|
|
45
|
+
const m = document.getElementById('msg');
|
|
46
|
+
m.style.display = 'block';
|
|
47
|
+
m.textContent = text;
|
|
48
|
+
post('/exit');
|
|
49
|
+
}
|
|
50
|
+
async function write(payload, exit) {
|
|
51
|
+
const res = await post('/save', { ...payload, mtime: D.mtime });
|
|
52
|
+
if (res.ok) {
|
|
53
|
+
D.mtime = res.body.mtime;
|
|
54
|
+
send({ action: 'status', message: 'Saved to ' + D.name, modified: false });
|
|
55
|
+
if (exit) finish('Saved ' + D.name + '. You can close this tab.');
|
|
56
|
+
} else {
|
|
57
|
+
send({ action: 'dialog', title: 'Not saved to ' + D.name, message: res.body.error || 'save failed', button: 'OK' });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
window.addEventListener('message', (e) => {
|
|
61
|
+
if (e.source !== f.contentWindow || typeof e.data !== 'string') return;
|
|
62
|
+
let m; try { m = JSON.parse(e.data); } catch { return; }
|
|
63
|
+
if (m.event === 'init') send({ action: 'load', xml: D.xml, title: D.name, autosave: 0, modified: 'unsavedChanges' });
|
|
64
|
+
else if (m.event === 'save') {
|
|
65
|
+
if (D.format === 'xml') write({ xml: m.xml }, m.exit);
|
|
66
|
+
else { pending = { exit: m.exit }; send({ action: 'export', format: 'xmlsvg', xml: m.xml, spinKey: 'saving' }); }
|
|
67
|
+
} else if (m.event === 'export' && pending) {
|
|
68
|
+
const p = pending; pending = null;
|
|
69
|
+
write({ data: m.data }, p.exit);
|
|
70
|
+
} else if (m.event === 'exit') finish('Closed ' + D.name + '. You can close this tab.');
|
|
71
|
+
});
|
|
72
|
+
window.addEventListener('pagehide', () => navigator.sendBeacon(base + '/closed'));
|
|
73
|
+
</script></body></html>`;
|
|
74
|
+
}
|
|
75
|
+
export async function startEditor(file, opts = {}) {
|
|
76
|
+
const abs = path.resolve(file);
|
|
77
|
+
if (!fs.existsSync(abs))
|
|
78
|
+
throw new Error(`${file} doesn't exist. Create it with linework build first.`);
|
|
79
|
+
const format = abs.endsWith('.svg') ? 'svg' : 'xml';
|
|
80
|
+
extractMxfile(fs.readFileSync(abs, 'utf8')); // fail early on files draw.io can't open
|
|
81
|
+
const assets = assetsDir();
|
|
82
|
+
if (!fs.existsSync(path.join(assets, 'index.html')))
|
|
83
|
+
throw new Error('linework: the bundled draw.io editor is missing (assets/drawio/index.html). Reinstall linework.');
|
|
84
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
85
|
+
const result = { saves: 0, conflicts: [] };
|
|
86
|
+
let resolveDone;
|
|
87
|
+
const done = new Promise((r) => (resolveDone = r));
|
|
88
|
+
let closeTimer;
|
|
89
|
+
let finished = false;
|
|
90
|
+
const server = http.createServer((req, res) => {
|
|
91
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
92
|
+
const json = (code, body) => {
|
|
93
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
94
|
+
res.end(JSON.stringify(body));
|
|
95
|
+
};
|
|
96
|
+
if (url.pathname === `/${token}/` || url.pathname === `/${token}`) {
|
|
97
|
+
clearTimeout(closeTimer); // a reload, not a close
|
|
98
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
99
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
100
|
+
return void res.end(hostPage(path.basename(abs), extractMxfile(text), fs.statSync(abs).mtimeMs, token, format));
|
|
101
|
+
}
|
|
102
|
+
if (req.method === 'POST' && url.pathname.startsWith(`/${token}/`)) {
|
|
103
|
+
const action = url.pathname.slice(token.length + 2);
|
|
104
|
+
let body = '';
|
|
105
|
+
req.on('data', (c) => (body += c));
|
|
106
|
+
req.on('end', async () => {
|
|
107
|
+
try {
|
|
108
|
+
if (action === 'save') {
|
|
109
|
+
const msg = JSON.parse(body);
|
|
110
|
+
let content = msg.data ? decodeDataUri(msg.data) : msg.xml;
|
|
111
|
+
// draw.io writes a fresh SVG without our fonts; put them back so the file
|
|
112
|
+
// still renders in the diagram's typeface everywhere.
|
|
113
|
+
if (content && msg.data)
|
|
114
|
+
content = await embedFonts(content);
|
|
115
|
+
if (!content)
|
|
116
|
+
return json(400, { error: 'empty save' });
|
|
117
|
+
let target = abs;
|
|
118
|
+
if (fs.statSync(abs).mtimeMs !== msg.mtime) {
|
|
119
|
+
// Someone (often an agent running linework build) changed the file while it was open.
|
|
120
|
+
target = conflictPath(abs);
|
|
121
|
+
result.conflicts.push(target);
|
|
122
|
+
}
|
|
123
|
+
fs.writeFileSync(target, content);
|
|
124
|
+
result.saves++;
|
|
125
|
+
if (target !== abs)
|
|
126
|
+
return json(409, { error: `${path.basename(abs)} changed on disk after you opened it, so your version went to ${path.basename(target)} instead. Reload this page to edit the new version.` });
|
|
127
|
+
return json(200, { mtime: fs.statSync(abs).mtimeMs });
|
|
128
|
+
}
|
|
129
|
+
if (action === 'exit') {
|
|
130
|
+
json(200, {});
|
|
131
|
+
return void finish();
|
|
132
|
+
}
|
|
133
|
+
if (action === 'closed') {
|
|
134
|
+
json(200, {});
|
|
135
|
+
closeTimer = setTimeout(finish, 3000);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
json(404, { error: 'unknown action' });
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
json(500, { error: e.message });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (req.method === 'GET' && url.pathname.startsWith('/aiviz-fonts/')) {
|
|
147
|
+
const f = FACES.find((x) => `/aiviz-fonts/${x.file}` === url.pathname);
|
|
148
|
+
if (!f) {
|
|
149
|
+
res.writeHead(404);
|
|
150
|
+
return void res.end();
|
|
151
|
+
}
|
|
152
|
+
res.writeHead(200, { 'content-type': 'font/woff2' });
|
|
153
|
+
return void fs.createReadStream(path.join(fontsDir(), f.file)).pipe(res);
|
|
154
|
+
}
|
|
155
|
+
if (req.method === 'GET' && url.pathname === '/app/index.html') {
|
|
156
|
+
// The editor shows labels in the diagram's own fonts.
|
|
157
|
+
const html = fs.readFileSync(path.join(assets, 'index.html'), 'utf8').replace('</head>', `<style>${fontFaceCss((f) => `/aiviz-fonts/${f}`)}</style></head>`);
|
|
158
|
+
res.writeHead(200, { 'content-type': TYPES['.html'] });
|
|
159
|
+
return void res.end(html);
|
|
160
|
+
}
|
|
161
|
+
if (req.method === 'GET' && url.pathname.startsWith('/app/')) {
|
|
162
|
+
const rel = path.normalize(decodeURIComponent(url.pathname.slice(5)));
|
|
163
|
+
const p = path.join(assets, rel);
|
|
164
|
+
if (!p.startsWith(assets + path.sep) || !fs.existsSync(p) || !fs.statSync(p).isFile()) {
|
|
165
|
+
res.writeHead(404);
|
|
166
|
+
return void res.end();
|
|
167
|
+
}
|
|
168
|
+
res.writeHead(200, { 'content-type': TYPES[path.extname(p)] ?? 'application/octet-stream' });
|
|
169
|
+
return void fs.createReadStream(p).pipe(res);
|
|
170
|
+
}
|
|
171
|
+
res.writeHead(404);
|
|
172
|
+
res.end();
|
|
173
|
+
});
|
|
174
|
+
function finish() {
|
|
175
|
+
if (finished)
|
|
176
|
+
return;
|
|
177
|
+
finished = true;
|
|
178
|
+
server.close();
|
|
179
|
+
server.closeAllConnections();
|
|
180
|
+
resolveDone(result);
|
|
181
|
+
}
|
|
182
|
+
await new Promise((resolve, reject) => {
|
|
183
|
+
server.once('error', reject);
|
|
184
|
+
server.listen(opts.port ?? 0, '127.0.0.1', resolve);
|
|
185
|
+
});
|
|
186
|
+
const port = server.address().port;
|
|
187
|
+
return { url: `http://127.0.0.1:${port}/${token}/`, done, close: async () => finish() };
|
|
188
|
+
}
|
|
189
|
+
function decodeDataUri(uri) {
|
|
190
|
+
const m = /^data:[^,]*?(;base64)?,(.*)$/s.exec(uri);
|
|
191
|
+
if (!m)
|
|
192
|
+
return uri;
|
|
193
|
+
return m[1] ? Buffer.from(m[2], 'base64').toString('utf8') : decodeURIComponent(m[2]);
|
|
194
|
+
}
|
|
195
|
+
function conflictPath(abs) {
|
|
196
|
+
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
|
197
|
+
const m = /^(.*?)((?:\.drawio)?\.(?:svg|drawio|xml))$/.exec(abs);
|
|
198
|
+
return m ? `${m[1]}.edited-${stamp}${m[2]}` : `${abs}.edited-${stamp}`;
|
|
199
|
+
}
|
|
200
|
+
/** Opens a URL in the user's default browser. Best effort. */
|
|
201
|
+
export function openInBrowser(url) {
|
|
202
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]] : ['xdg-open', [url]];
|
|
203
|
+
try {
|
|
204
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
205
|
+
child.on('error', () => { });
|
|
206
|
+
child.unref();
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `linework git-setup`: make `git diff` show diagram changes as spec changes.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const PATTERNS = ['*.drawio.svg diff=linework', '*.drawio diff=linework'];
|
|
7
|
+
function onPath(cmd) {
|
|
8
|
+
try {
|
|
9
|
+
execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' });
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The command git should run. Prefer a stable name over this machine's paths. */
|
|
17
|
+
export function cliCommand() {
|
|
18
|
+
const cli = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
19
|
+
if (onPath('linework'))
|
|
20
|
+
return 'linework';
|
|
21
|
+
if (cli.includes(`${path.sep}_npx${path.sep}`))
|
|
22
|
+
return 'npx -y @itsthecsguy/linework';
|
|
23
|
+
return `"${process.execPath}" "${cli.replace(/\.ts$/, '.js').replace(`${path.sep}src${path.sep}`, `${path.sep}dist${path.sep}`)}"`;
|
|
24
|
+
}
|
|
25
|
+
export function gitSetup(cwd = process.cwd()) {
|
|
26
|
+
let top;
|
|
27
|
+
try {
|
|
28
|
+
top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' }).trim();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error('not inside a git repository');
|
|
32
|
+
}
|
|
33
|
+
const done = [];
|
|
34
|
+
const attrs = path.join(top, '.gitattributes');
|
|
35
|
+
const existing = fs.existsSync(attrs) ? fs.readFileSync(attrs, 'utf8') : '';
|
|
36
|
+
const missing = PATTERNS.filter((p) => !existing.split('\n').some((l) => l.trim() === p));
|
|
37
|
+
if (missing.length) {
|
|
38
|
+
fs.writeFileSync(attrs, existing + (existing && !existing.endsWith('\n') ? '\n' : '') + missing.join('\n') + '\n');
|
|
39
|
+
done.push(`added to .gitattributes: ${missing.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
const cmd = `${cliCommand()} show --geometry --quiet`;
|
|
42
|
+
execFileSync('git', ['config', 'diff.linework.textconv', cmd], { cwd: top });
|
|
43
|
+
execFileSync('git', ['config', 'diff.linework.cachetextconv', 'true'], { cwd: top });
|
|
44
|
+
done.push(`set git config diff.linework.textconv to: ${cmd} (this repo only)`);
|
|
45
|
+
return done;
|
|
46
|
+
}
|