@shortlink-org/portolan 0.2.2 → 0.2.3
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 +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/portolan-go.wasm +0 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +16 -0
- package/src/catalog.ts +6 -3306
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/derive.ts +1 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/merge.test.ts +46 -0
- package/src/merge.ts +35 -1
- package/src/pages/Settings.tsx +1 -126
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import { NavLink, useLocation, useMatch } from "react-router";
|
|
4
|
+
|
|
5
|
+
import { KindIcon } from "../components/kind";
|
|
6
|
+
import type { LeafKind } from "../lib/kinds";
|
|
7
|
+
import { m, transitions, Unfold } from "../lib/motion";
|
|
8
|
+
import { selectionHash } from "../selection/hash";
|
|
9
|
+
import { selectionFor } from "../selection/model";
|
|
10
|
+
import { selectsInPlace } from "../selection/pages";
|
|
11
|
+
import { useSelectionStore } from "../selection/store";
|
|
12
|
+
|
|
13
|
+
export const indent = (depth: number) => 8 + depth * 12;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The selection's light: the raised background and the accent edge of the
|
|
17
|
+
* row that is selected. One element for the whole tree - a new selection is
|
|
18
|
+
* the same light arriving on another row, and it travels there on the settle
|
|
19
|
+
* spring rather than going out here and coming on over there. `-left-0.5`
|
|
20
|
+
* puts its edge over the row's own transparent 2px border, so the text does
|
|
21
|
+
* not move.
|
|
22
|
+
*/
|
|
23
|
+
function SelectionLight() {
|
|
24
|
+
return (
|
|
25
|
+
<m.span
|
|
26
|
+
layoutId="selection"
|
|
27
|
+
aria-hidden
|
|
28
|
+
className="pointer-events-none absolute inset-y-0 right-0 -left-0.5 -z-10 border-l-2 border-accent"
|
|
29
|
+
style={{ background: "var(--surface-2)" }}
|
|
30
|
+
transition={transitions.settle}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
export function Chevron({ open }: { open: boolean }) {
|
|
35
|
+
return (
|
|
36
|
+
<ChevronRight
|
|
37
|
+
size={14}
|
|
38
|
+
aria-hidden
|
|
39
|
+
className="block shrink-0 t-micro transition-transform"
|
|
40
|
+
style={{
|
|
41
|
+
transform: open ? "rotate(90deg)" : "none",
|
|
42
|
+
color: "var(--fg-muted)",
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Clicking an unselected row selects it. Whether it also navigates is the
|
|
50
|
+
* question the page on screen answers: on a flow page, picking something that
|
|
51
|
+
* flow already draws is a question about that sequence, so the reader stays
|
|
52
|
+
* put. Clicking the highlighted row again follows its link to its own page.
|
|
53
|
+
*
|
|
54
|
+
* Only the in-place case writes the store here. A row that navigates carries
|
|
55
|
+
* its selection in the link's hash instead (see `rowTarget`): writing the
|
|
56
|
+
* store first and then letting the link go would race SelectionSync, whose
|
|
57
|
+
* `replace` lands with the pathname it rendered with - the page being left -
|
|
58
|
+
* and undoes the navigation. A selection in the URL wins on arrival, so one
|
|
59
|
+
* write does both jobs. The palette works the same way.
|
|
60
|
+
*/
|
|
61
|
+
function useRowClick(selId: string | undefined) {
|
|
62
|
+
const select = useSelectionStore((s) => s.select);
|
|
63
|
+
const { pathname } = useLocation();
|
|
64
|
+
return (e: React.MouseEvent) => {
|
|
65
|
+
if (selId === undefined) return;
|
|
66
|
+
const selection = selectionFor(selId);
|
|
67
|
+
const current = useSelectionStore.getState().selection;
|
|
68
|
+
if (!selectsInPlace(pathname, selection, current)) return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
select(selId, "sidebar");
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where a row's link goes: its page, with the row's selection in the hash. */
|
|
75
|
+
function rowTarget(to: string, selId: string | undefined): string {
|
|
76
|
+
if (selId === undefined || to.includes("#")) return to;
|
|
77
|
+
return `${to}${selectionHash(selectionFor(selId))}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A leaf: the whole row navigates. Rows that stand for a selectable entity also
|
|
82
|
+
* carry `selId`, which makes them both a writer of the selection and a reader
|
|
83
|
+
* of it - the row lights up whether it was clicked here, on a diagram, or in
|
|
84
|
+
* the palette.
|
|
85
|
+
*/
|
|
86
|
+
export function Leaf({
|
|
87
|
+
to,
|
|
88
|
+
depth,
|
|
89
|
+
children,
|
|
90
|
+
title,
|
|
91
|
+
selId,
|
|
92
|
+
}: {
|
|
93
|
+
to: string;
|
|
94
|
+
depth: number;
|
|
95
|
+
children: React.ReactNode;
|
|
96
|
+
title?: string;
|
|
97
|
+
selId?: string;
|
|
98
|
+
}) {
|
|
99
|
+
const selected = useSelectionStore(
|
|
100
|
+
(s) => selId !== undefined && s.selection?.id === selId,
|
|
101
|
+
);
|
|
102
|
+
const onClick = useRowClick(selId);
|
|
103
|
+
return (
|
|
104
|
+
<NavLink
|
|
105
|
+
to={rowTarget(to, selId)}
|
|
106
|
+
end
|
|
107
|
+
title={title}
|
|
108
|
+
data-sel={selId}
|
|
109
|
+
data-tree-row
|
|
110
|
+
data-tree-depth={depth}
|
|
111
|
+
onClick={onClick}
|
|
112
|
+
style={({ isActive }) => ({
|
|
113
|
+
paddingLeft: indent(depth),
|
|
114
|
+
background: isActive && !selected ? "var(--surface-2)" : undefined,
|
|
115
|
+
borderLeftWidth: 2,
|
|
116
|
+
borderLeftStyle: "solid",
|
|
117
|
+
borderLeftColor:
|
|
118
|
+
isActive && !selected ? "var(--accent)" : "transparent",
|
|
119
|
+
})}
|
|
120
|
+
/* The edge is always 2px, transparent when idle: lighting a row must not
|
|
121
|
+
shift the text beside it. The route's own row paints its light in
|
|
122
|
+
place; the SELECTION's light is one element that slides from the row
|
|
123
|
+
it was on to this one. */
|
|
124
|
+
data-nav-item
|
|
125
|
+
className="tree-row relative isolate flex items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
|
|
126
|
+
>
|
|
127
|
+
{selected ? <SelectionLight /> : null}
|
|
128
|
+
{children}
|
|
129
|
+
</NavLink>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A branch: a disclosure triangle that only expands, next to a link that only
|
|
135
|
+
* navigates. Keeping them separate means neither is nested inside the other.
|
|
136
|
+
*
|
|
137
|
+
* `end` is for controls that must be their own target - the unresolved-edge
|
|
138
|
+
* count on a context, which goes somewhere else entirely. It sits OUTSIDE the
|
|
139
|
+
* link, because an anchor may not contain another one, and the link gives up
|
|
140
|
+
* its claim on the rest of the row to make room for it.
|
|
141
|
+
*/
|
|
142
|
+
export function Branch({
|
|
143
|
+
to,
|
|
144
|
+
depth,
|
|
145
|
+
open,
|
|
146
|
+
onToggle,
|
|
147
|
+
label,
|
|
148
|
+
children,
|
|
149
|
+
right,
|
|
150
|
+
end,
|
|
151
|
+
under,
|
|
152
|
+
selId,
|
|
153
|
+
}: {
|
|
154
|
+
to: string;
|
|
155
|
+
depth: number;
|
|
156
|
+
open: boolean;
|
|
157
|
+
onToggle: () => void;
|
|
158
|
+
label: string;
|
|
159
|
+
children: React.ReactNode;
|
|
160
|
+
right?: React.ReactNode;
|
|
161
|
+
end?: React.ReactNode;
|
|
162
|
+
under?: React.ReactNode;
|
|
163
|
+
selId?: string;
|
|
164
|
+
}) {
|
|
165
|
+
const selected = useSelectionStore(
|
|
166
|
+
(s) => selId !== undefined && s.selection?.id === selId,
|
|
167
|
+
);
|
|
168
|
+
const onClick = useRowClick(selId);
|
|
169
|
+
const active = useMatch({ path: to, end: true }) !== null && !selected;
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
172
|
+
<div
|
|
173
|
+
className="tree-row group relative isolate flex items-stretch t-micro transition-colors hover:bg-surface"
|
|
174
|
+
style={{
|
|
175
|
+
paddingLeft: indent(depth),
|
|
176
|
+
background: active ? "var(--surface-2)" : undefined,
|
|
177
|
+
borderLeftWidth: 2,
|
|
178
|
+
borderLeftStyle: "solid",
|
|
179
|
+
borderLeftColor: active ? "var(--accent)" : "transparent",
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{selected ? <SelectionLight /> : null}
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={onToggle}
|
|
186
|
+
aria-expanded={open}
|
|
187
|
+
aria-label={`${open ? "Collapse" : "Expand"} ${label}`}
|
|
188
|
+
data-tree-toggle
|
|
189
|
+
className="flex shrink-0 items-center pr-1"
|
|
190
|
+
>
|
|
191
|
+
<Chevron open={open} />
|
|
192
|
+
</button>
|
|
193
|
+
<NavLink
|
|
194
|
+
to={rowTarget(to, selId)}
|
|
195
|
+
end
|
|
196
|
+
data-sel={selId}
|
|
197
|
+
data-nav-item
|
|
198
|
+
data-tree-row
|
|
199
|
+
data-tree-depth={depth}
|
|
200
|
+
data-tree-open={open}
|
|
201
|
+
onClick={onClick}
|
|
202
|
+
className={`flex min-w-0 items-center gap-1.5 py-[3px] pr-2 ${
|
|
203
|
+
end ? "shrink" : "flex-1"
|
|
204
|
+
}`}
|
|
205
|
+
>
|
|
206
|
+
{children}
|
|
207
|
+
{right && !end ? (
|
|
208
|
+
<span className="ml-auto shrink-0">{right}</span>
|
|
209
|
+
) : null}
|
|
210
|
+
</NavLink>
|
|
211
|
+
{end}
|
|
212
|
+
{right && end ? (
|
|
213
|
+
<span className="flex shrink-0 items-center pr-2">{right}</span>
|
|
214
|
+
) : null}
|
|
215
|
+
</div>
|
|
216
|
+
{under}
|
|
217
|
+
</>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A group header inside an aggregate - "value objects (3)". It names a kind
|
|
223
|
+
* rather than a thing, so it has no page and does nothing but open and close.
|
|
224
|
+
*/
|
|
225
|
+
export function Group({
|
|
226
|
+
kind,
|
|
227
|
+
label,
|
|
228
|
+
count,
|
|
229
|
+
open,
|
|
230
|
+
onToggle,
|
|
231
|
+
depth,
|
|
232
|
+
children,
|
|
233
|
+
}: {
|
|
234
|
+
kind: LeafKind;
|
|
235
|
+
/** Overrides the kind's own plural — the stores group is "data", not "tables". */
|
|
236
|
+
label?: string;
|
|
237
|
+
count: number;
|
|
238
|
+
open: boolean;
|
|
239
|
+
onToggle: () => void;
|
|
240
|
+
depth: number;
|
|
241
|
+
children: React.ReactNode;
|
|
242
|
+
}) {
|
|
243
|
+
if (count === 0) return null;
|
|
244
|
+
return (
|
|
245
|
+
<>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={onToggle}
|
|
249
|
+
aria-expanded={open}
|
|
250
|
+
data-tree-row
|
|
251
|
+
data-tree-depth={depth}
|
|
252
|
+
data-tree-open={open}
|
|
253
|
+
style={{ paddingLeft: indent(depth) }}
|
|
254
|
+
className="tree-row group-row"
|
|
255
|
+
>
|
|
256
|
+
<Chevron open={open} />
|
|
257
|
+
<KindIcon kind={kind} />
|
|
258
|
+
<span>
|
|
259
|
+
{label ?? KIND_GROUP_LABEL[kind]} ({count})
|
|
260
|
+
</span>
|
|
261
|
+
</button>
|
|
262
|
+
<Unfold open={open}>{children}</Unfold>
|
|
263
|
+
</>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const KIND_GROUP_LABEL: Record<LeafKind, string> = {
|
|
268
|
+
vo: "value objects",
|
|
269
|
+
entity: "entities",
|
|
270
|
+
enum: "enums",
|
|
271
|
+
event: "events",
|
|
272
|
+
command: "commands",
|
|
273
|
+
query: "queries",
|
|
274
|
+
endpoint: "api",
|
|
275
|
+
table: "tables",
|
|
276
|
+
view: "views",
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Sections
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A top-level band of the tree. Its header folds it and always states its size:
|
|
285
|
+
* a folded section that also hid its count would be indistinguishable from an
|
|
286
|
+
* empty one, and the reader would have to open it to find out which.
|
|
287
|
+
*
|
|
288
|
+
* 12px above the header and nothing else between sections. The tree is one
|
|
289
|
+
* continuous list; boxing each band would make a reader count boxes.
|
|
290
|
+
*/
|
|
291
|
+
export function Section({
|
|
292
|
+
title,
|
|
293
|
+
count,
|
|
294
|
+
open,
|
|
295
|
+
onToggle,
|
|
296
|
+
first = false,
|
|
297
|
+
children,
|
|
298
|
+
}: {
|
|
299
|
+
title: string;
|
|
300
|
+
count: number;
|
|
301
|
+
open: boolean;
|
|
302
|
+
onToggle: () => void;
|
|
303
|
+
/** The first section needs no air above it - the header is already there. */
|
|
304
|
+
first?: boolean;
|
|
305
|
+
children: React.ReactNode;
|
|
306
|
+
}) {
|
|
307
|
+
return (
|
|
308
|
+
<div className={first ? "" : "mt-3"}>
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={onToggle}
|
|
312
|
+
aria-expanded={open}
|
|
313
|
+
className="sb-section"
|
|
314
|
+
>
|
|
315
|
+
<Chevron open={open} />
|
|
316
|
+
{title}
|
|
317
|
+
<span className="sb-count">{count}</span>
|
|
318
|
+
</button>
|
|
319
|
+
<Unfold open={open}>{children}</Unfold>
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|