@shortlink-org/portolan 0.2.2 → 0.2.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.
Files changed (84) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/README.md +5 -0
  5. package/plugins/extract-python-kafka/README.md +6 -0
  6. package/plugins/extract-python-kafka/extract.py +2 -2
  7. package/plugins/extract-python-kafka/extract_test.py +17 -1
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/plugins/pyplugin/catalog.py +12 -1
  10. package/plugins/pyplugin/kafka.py +74 -3
  11. package/scripts/builtin-plugins.mjs +3 -2
  12. package/scripts/catalog-sources.mjs +2 -1
  13. package/scripts/catalog-sources.test.mjs +11 -1
  14. package/scripts/delivery-presets.mjs +207 -24
  15. package/scripts/diff.mjs +5 -1
  16. package/scripts/gen-likec4.mjs +78 -20
  17. package/scripts/gen-likec4.test.mjs +25 -2
  18. package/scripts/local-api.mjs +26 -377
  19. package/scripts/local-api.test.mjs +50 -0
  20. package/scripts/local-discovery.mjs +378 -0
  21. package/scripts/manifest.mjs +42 -1
  22. package/scripts/manifest.test.mjs +24 -1
  23. package/scripts/run-builtin.mjs +4 -2
  24. package/scripts/schema.mjs +4 -0
  25. package/scripts/site-docs.mjs +2 -2
  26. package/src/app/Breadcrumbs.test.ts +4 -0
  27. package/src/app/Breadcrumbs.tsx +1 -0
  28. package/src/app/CatalogApp.tsx +4 -4
  29. package/src/app/Sidebar.tsx +13 -730
  30. package/src/app/SidebarFlowSections.tsx +251 -0
  31. package/src/app/SidebarFooter.tsx +160 -0
  32. package/src/app/SidebarTree.tsx +322 -0
  33. package/src/catalog-index.ts +485 -0
  34. package/src/catalog-model.ts +1360 -0
  35. package/src/catalog-stores.test.ts +17 -0
  36. package/src/catalog-validation.ts +1611 -0
  37. package/src/catalog.test.ts +29 -2
  38. package/src/catalog.ts +6 -3306
  39. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  40. package/src/components/ChannelRows.test.tsx +54 -0
  41. package/src/components/ChannelRows.tsx +57 -10
  42. package/src/components/LifecycleDiagram.tsx +28 -12
  43. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  44. package/src/components/ProblemRow.tsx +7 -0
  45. package/src/components/SourcePreview.tsx +1 -1
  46. package/src/components/WhatLinksHere.tsx +6 -4
  47. package/src/enrich.test.ts +4 -5
  48. package/src/flow/StepDetail.tsx +98 -54
  49. package/src/flow/answers.test.ts +18 -1
  50. package/src/flow/answers.ts +37 -8
  51. package/src/index.css +0 -17
  52. package/src/landing/LandingPage.tsx +4 -4
  53. package/src/lib/all-problems.ts +1 -1
  54. package/src/lib/backlinks.test.ts +16 -1
  55. package/src/lib/backlinks.ts +20 -0
  56. package/src/lib/catalog-diff.test.ts +18 -0
  57. package/src/lib/catalog-diff.ts +19 -1
  58. package/src/lib/derive.ts +2 -0
  59. package/src/lib/kafka-ui.test.ts +87 -0
  60. package/src/lib/kafka-ui.ts +105 -0
  61. package/src/lib/local-api.ts +19 -7
  62. package/src/lib/motion.test.ts +4 -2
  63. package/src/lib/motion.tsx +5 -4
  64. package/src/lib/proto-problems.test.ts +170 -3
  65. package/src/lib/proto-problems.ts +176 -4
  66. package/src/lib/wire-problems.test.ts +21 -0
  67. package/src/lib/wire-problems.ts +62 -1
  68. package/src/likec4/FlowView.tsx +2 -6
  69. package/src/likec4/flow-edges.test.ts +64 -1
  70. package/src/likec4/flow-edges.ts +43 -7
  71. package/src/likec4/view-index.ts +8 -2
  72. package/src/merge.test.ts +69 -0
  73. package/src/merge.ts +52 -2
  74. package/src/pages/CatalogFailure.tsx +2 -2
  75. package/src/pages/Settings.tsx +12 -129
  76. package/src/pages/settings/AboutSettings.tsx +129 -0
  77. package/src/pages/settings/DeliverySettings.tsx +71 -15
  78. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  79. package/src/routes.test.ts +2 -0
  80. package/src/routes.ts +2 -1
  81. package/src/selection/DetailPanel.tsx +46 -1
  82. package/src/selection/pages.test.ts +14 -1
  83. package/src/selection/pages.ts +9 -3
  84. package/vite.config.ts +3 -1
@@ -0,0 +1,251 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { GripVertical, PinOff, TriangleAlert } from "lucide-react";
3
+ import { NavLink } from "react-router";
4
+
5
+ import { KindIcon } from "../components/kind";
6
+ import { contextName, ctxStyle } from "../lib/context-color";
7
+ import {
8
+ FLOW_HEALTH_NOTE,
9
+ reachDots,
10
+ visibleEntries,
11
+ } from "../lib/flow-tree";
12
+ import type { FlowEntry, FlowHealth, FlowGroup } from "../lib/flow-tree";
13
+ import { Unfold } from "../lib/motion";
14
+ import { paths } from "../routes";
15
+ import { resolvePin, usePinsStore } from "./pins";
16
+ import { Chevron, Leaf, Section, indent } from "./SidebarTree";
17
+
18
+ const HEALTH_COLOR: Record<FlowHealth, string> = {
19
+ unresolved: "var(--status-unresolved)",
20
+ declared: "var(--fg-faint)",
21
+ verified: "var(--status-verified)",
22
+ };
23
+
24
+ /**
25
+ * One flow. Its name is what the row is for; the two marks at the end answer
26
+ * the two questions a reader has before opening it - how far does it travel,
27
+ * and can I believe it.
28
+ */
29
+ function FlowRow({ entry }: { entry: FlowEntry }) {
30
+ const { flow, health, reach } = entry;
31
+ const { dots, more } = reachDots(reach);
32
+ const crosses =
33
+ reach.length === 0
34
+ ? "stays inside its own context"
35
+ : `crosses into ${reach.join(", ")}`;
36
+ return (
37
+ <Leaf
38
+ to={paths.flow(flow.slug)}
39
+ depth={1}
40
+ title={`${flow.slug} — ${crosses}`}
41
+ >
42
+ <KindIcon kind="flow" />
43
+ <span className="truncate">{flow.name}</span>
44
+ <span className="ml-auto flex shrink-0 items-center gap-2 pl-2">
45
+ <span className="flex items-center gap-0.5" aria-hidden>
46
+ {dots.map((c) => (
47
+ <span key={c} className="sb-reach" style={ctxStyle(c)} />
48
+ ))}
49
+ {more > 0 ? (
50
+ <span className="mono" style={{ color: "var(--fg-faint)" }}>
51
+ +{more}
52
+ </span>
53
+ ) : null}
54
+ </span>
55
+ <span
56
+ className="sb-health"
57
+ title={FLOW_HEALTH_NOTE[health]}
58
+ style={{ background: HEALTH_COLOR[health] }}
59
+ />
60
+ </span>
61
+ </Leaf>
62
+ );
63
+ }
64
+ /**
65
+ * The flows one context owns. Grouping them by owner rather than listing them
66
+ * flat is what makes the section answer "whose flows are these" - and the
67
+ * unowned group is a defect report: with a valid catalog it is never drawn.
68
+ */
69
+ export function FlowGroupNode({
70
+ group,
71
+ open,
72
+ onToggle,
73
+ }: {
74
+ group: FlowGroup;
75
+ open: boolean;
76
+ onToggle: () => void;
77
+ }) {
78
+ const { shown, hidden } = visibleEntries(group.entries);
79
+ const owner = group.owner;
80
+ const label = owner === null ? "unowned" : contextName(owner);
81
+ return (
82
+ <div>
83
+ <button
84
+ type="button"
85
+ onClick={onToggle}
86
+ aria-expanded={open}
87
+ style={{ paddingLeft: indent(0) }}
88
+ title={
89
+ owner === null
90
+ ? "these flows name no owner — a valid catalog has none of these"
91
+ : `flows owned by ${owner}`
92
+ }
93
+ className="tree-row flex w-full items-center gap-1.5 py-[3px] pr-2 text-left t-micro transition-colors hover:bg-surface"
94
+ >
95
+ <Chevron open={open} />
96
+ {owner === null ? (
97
+ <TriangleAlert
98
+ size={13}
99
+ aria-hidden
100
+ className="block shrink-0 text-unresolved"
101
+ />
102
+ ) : (
103
+ <KindIcon kind="context" contextId={owner} />
104
+ )}
105
+ <span
106
+ className="truncate"
107
+ style={owner === null ? { color: "var(--status-unresolved)" } : {}}
108
+ >
109
+ {label}
110
+ </span>
111
+ <span className="sb-count">{group.entries.length}</span>
112
+ </button>
113
+ <Unfold open={open}>
114
+ <>
115
+ {shown.map((entry) => (
116
+ <FlowRow key={entry.flow.slug} entry={entry} />
117
+ ))}
118
+ {hidden > 0 && owner !== null ? (
119
+ <NavLink
120
+ to={`${paths.flows()}?owner=${encodeURIComponent(owner)}`}
121
+ data-nav-item
122
+ style={{ paddingLeft: indent(1) }}
123
+ className="tree-row mono flex items-center py-[3px] pr-2 text-accent hover:bg-surface"
124
+ >
125
+ view all {group.entries.length} →
126
+ </NavLink>
127
+ ) : null}
128
+ </>
129
+ </Unfold>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Pins
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * The reader's own shortlist, above everything the catalog decided. It only
140
+ * exists while there is something on it: an empty band with a header would
141
+ * teach the feature by taking up room, which is the one thing the pane cannot
142
+ * spare.
143
+ */
144
+ export function PinnedSection({
145
+ open,
146
+ onToggle,
147
+ }: {
148
+ open: boolean;
149
+ onToggle: () => void;
150
+ }) {
151
+ const pins = usePinsStore((s) => s.pins);
152
+ const toggle = usePinsStore((s) => s.toggle);
153
+ const reorder = usePinsStore((s) => s.reorder);
154
+ // Which row is under the cursor is a ref, not state: a drop fires in the same
155
+ // gesture that started the drag, and a state write scheduled by `dragstart`
156
+ // is not guaranteed to have landed by the time `drop` reads it. The state
157
+ // beside it only dims the row, so it is allowed to arrive late.
158
+ const dragFrom = useRef<number | null>(null);
159
+ const [dragging, setDragging] = useState<number | null>(null);
160
+
161
+ // Resolved at render, so a pin taken against a build that no longer has the
162
+ // event simply stops being drawn rather than pointing into nothing.
163
+ const rows = useMemo(
164
+ () =>
165
+ pins
166
+ .map((pin, at) => ({ at, resolved: resolvePin(pin) }))
167
+ .filter(
168
+ (r): r is { at: number; resolved: NonNullable<typeof r.resolved> } =>
169
+ r.resolved !== null,
170
+ ),
171
+ [pins],
172
+ );
173
+ if (rows.length === 0) return null;
174
+
175
+ return (
176
+ <Section
177
+ title="Pinned"
178
+ count={rows.length}
179
+ open={open}
180
+ onToggle={onToggle}
181
+ first
182
+ >
183
+ {rows.map(({ at, resolved }) => (
184
+ <div
185
+ key={`${resolved.pin.kind}:${resolved.pin.id}`}
186
+ draggable
187
+ onDragStart={(e) => {
188
+ dragFrom.current = at;
189
+ e.dataTransfer.effectAllowed = "move";
190
+ setDragging(at);
191
+ }}
192
+ onDragEnd={() => {
193
+ dragFrom.current = null;
194
+ setDragging(null);
195
+ }}
196
+ onDragOver={(e) => {
197
+ e.preventDefault();
198
+ e.dataTransfer.dropEffect = "move";
199
+ }}
200
+ onDrop={(e) => {
201
+ e.preventDefault();
202
+ const from = dragFrom.current;
203
+ if (from !== null) reorder(from, at);
204
+ dragFrom.current = null;
205
+ setDragging(null);
206
+ }}
207
+ className={`group relative flex items-stretch ${
208
+ dragging === at ? "opacity-50" : ""
209
+ }`}
210
+ >
211
+ <NavLink
212
+ to={resolved.path}
213
+ end
214
+ draggable={false}
215
+ data-nav-item
216
+ title={resolved.title}
217
+ style={({ isActive }) => ({
218
+ paddingLeft: indent(0),
219
+ background: isActive ? "var(--surface-2)" : undefined,
220
+ borderLeftWidth: 2,
221
+ borderLeftStyle: "solid",
222
+ borderLeftColor: isActive ? "var(--accent)" : "transparent",
223
+ })}
224
+ className="tree-row flex min-w-0 flex-1 items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
225
+ >
226
+ <GripVertical
227
+ size={12}
228
+ aria-hidden
229
+ className="block shrink-0 opacity-0 group-hover:opacity-100"
230
+ style={{ color: "var(--fg-faint)" }}
231
+ />
232
+ <KindIcon
233
+ kind={resolved.kind}
234
+ {...(resolved.contextId ? { contextId: resolved.contextId } : {})}
235
+ />
236
+ <span className="truncate">{resolved.name}</span>
237
+ </NavLink>
238
+ <button
239
+ type="button"
240
+ onClick={() => toggle(resolved.pin)}
241
+ aria-label={`Unpin ${resolved.name}`}
242
+ title={`Unpin ${resolved.name}`}
243
+ className="flex shrink-0 items-center px-2 text-muted opacity-0 t-micro transition-opacity hover:text-ink group-hover:opacity-100 focus-visible:opacity-100"
244
+ >
245
+ <PinOff size={13} aria-hidden />
246
+ </button>
247
+ </div>
248
+ ))}
249
+ </Section>
250
+ );
251
+ }
@@ -0,0 +1,160 @@
1
+ import { useMemo } from "react";
2
+ import { NavLink } from "react-router";
3
+ import {
4
+ Check,
5
+ PanelLeftOpen,
6
+ Settings2,
7
+ TriangleAlert,
8
+ } from "lucide-react";
9
+
10
+ import { allModules } from "../catalog";
11
+ import { KindIcon } from "../components/kind";
12
+ import { CompassRose } from "../components/logo";
13
+ import { catalog, index } from "../data";
14
+ import { allProblems } from "../lib/all-problems";
15
+ import type { Kind } from "../lib/kinds";
16
+ import { paths } from "../routes";
17
+
18
+ const indent = (depth: number) => 8 + depth * 12;
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // The bottom group. A sibling of the scroller rather than a row inside it, so
22
+ // "pinned to the bottom" is a fact about the layout and not about how far the
23
+ // tree happens to have been scrolled.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function SidebarBottomGroup() {
27
+ // The same complete list as the Problems page, because that is what the row
28
+ // opens. The badge is red while anything on it is an error and amber when
29
+ // only the schema disagrees with itself - a page of warnings is not a clean
30
+ // estate, and a green tick over it would be the one lie the row can tell.
31
+ const found = useMemo(() => allProblems(catalog, index), []);
32
+ const errors = found.filter((p) => p.severity === "error").length;
33
+ const colour =
34
+ errors > 0 ? "var(--status-unresolved)" : "var(--status-declared)";
35
+
36
+ return (
37
+ <div className="shrink-0 border-t bg-canvas border-line py-0.5">
38
+ <NavLink
39
+ to={paths.problems()}
40
+ end
41
+ data-nav-item
42
+ aria-keyshortcuts="g p"
43
+ title={
44
+ found.length === 0
45
+ ? "Problems — every edge in the catalog lands somewhere"
46
+ : `Problems — ${found.length} to look at`
47
+ }
48
+ style={({ isActive }) => ({
49
+ paddingLeft: indent(0),
50
+ background: isActive ? "var(--surface-2)" : undefined,
51
+ borderLeftWidth: 2,
52
+ borderLeftStyle: "solid",
53
+ borderLeftColor: isActive ? "var(--accent)" : "transparent",
54
+ })}
55
+ className="tree-row flex items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
56
+ >
57
+ <TriangleAlert
58
+ size={14}
59
+ aria-hidden
60
+ className="block shrink-0"
61
+ style={{ color: found.length === 0 ? "var(--fg-muted)" : colour }}
62
+ />
63
+ <span className="truncate">Problems</span>
64
+ {found.length === 0 ? (
65
+ <Check
66
+ size={13}
67
+ aria-hidden
68
+ className="ml-auto block shrink-0 text-muted"
69
+ />
70
+ ) : (
71
+ <span
72
+ className="mono tnum ml-auto shrink-0"
73
+ style={{ color: colour }}
74
+ >
75
+ {found.length}
76
+ </span>
77
+ )}
78
+ </NavLink>
79
+ <NavLink
80
+ to={paths.settings()}
81
+ data-nav-item
82
+ title="Settings — projects, plugins and appearance"
83
+ style={({ isActive }) => ({
84
+ paddingLeft: indent(0),
85
+ background: isActive ? "var(--surface-2)" : undefined,
86
+ borderLeftWidth: 2,
87
+ borderLeftStyle: "solid",
88
+ borderLeftColor: isActive ? "var(--accent)" : "transparent",
89
+ })}
90
+ className="tree-row flex items-center gap-1.5 py-[3px] pr-2 text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
91
+ >
92
+ <Settings2 size={14} aria-hidden className="block shrink-0" />
93
+ <span className="truncate">Settings</span>
94
+ </NavLink>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * What is left of the tree at 48px. Not a menu: every button here does the one
101
+ * thing the rail can honestly offer, which is to give the tree its width back
102
+ * and land the reader on the section they pointed at.
103
+ */
104
+ export function SidebarIconRail({ onExpand }: { onExpand: () => void }) {
105
+ const sections: { key: string; kind: Kind; label: string }[] = [
106
+ { key: "flows", kind: "flow", label: "Flows" },
107
+ { key: "domains", kind: "context", label: "Contexts" },
108
+ // Conditional for the same reason the band is: at 48px a button that opens
109
+ // an empty section is worse than no button.
110
+ ...(allModules(catalog).length > 0
111
+ ? [{ key: "registry", kind: "module" as Kind, label: "Registry" }]
112
+ : []),
113
+ { key: "adrs", kind: "adr", label: "Decisions" },
114
+ ];
115
+ return (
116
+ <nav
117
+ className="flex h-full flex-col items-center gap-1 border-r py-3 border-line bg-canvas"
118
+ aria-label="Catalog (collapsed)"
119
+ >
120
+ {/* The mark survives the collapse; the wordmark does not fit and is not
121
+ missed - at 48px the rose is the whole identity. */}
122
+ <CompassRose size={18} className="mb-1 text-ink" />
123
+ <button
124
+ type="button"
125
+ onClick={onExpand}
126
+ title="Expand the catalog"
127
+ aria-label="Expand the catalog"
128
+ aria-expanded={false}
129
+ className="flex size-8 items-center justify-center rounded-control text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
130
+ >
131
+ <PanelLeftOpen size={16} aria-hidden />
132
+ </button>
133
+ <span aria-hidden className="my-1 h-px w-6 bg-line" />
134
+ {sections.map(({ key, kind, label }) => (
135
+ <button
136
+ key={key}
137
+ type="button"
138
+ onClick={onExpand}
139
+ title={`${label} — expand the catalog`}
140
+ aria-label={`${label} — expand the catalog`}
141
+ className="flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface"
142
+ >
143
+ <KindIcon kind={kind} size={16} />
144
+ </button>
145
+ ))}
146
+ <NavLink
147
+ to={paths.settings()}
148
+ title="Settings"
149
+ aria-label="Settings"
150
+ className={({ isActive }) =>
151
+ `mt-auto flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface ${
152
+ isActive ? "bg-surface text-accent" : "text-muted hover:text-ink"
153
+ }`
154
+ }
155
+ >
156
+ <Settings2 size={16} aria-hidden />
157
+ </NavLink>
158
+ </nav>
159
+ );
160
+ }