@shortlink-org/portolan 0.2.0 → 0.2.2
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/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +56 -7
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/catalog-sources.mjs +12 -1
- package/scripts/catalog-sources.test.mjs +35 -0
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +368 -34
- package/scripts/local-api.test.mjs +197 -2
- package/scripts/package-smoke.mjs +19 -2
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/schema.mjs +7 -0
- package/src/app/CatalogApp.tsx +19 -4
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/toast.tsx +11 -7
- package/src/catalog.test.ts +33 -0
- package/src/catalog.ts +190 -8
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.ts +24 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +124 -36
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -0
- package/src/landing/LandingPage.tsx +12 -8
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +17 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +55 -0
- package/src/merge.ts +30 -0
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +382 -44
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +5 -1
|
@@ -128,6 +128,7 @@ export function MethodRows({
|
|
|
128
128
|
open,
|
|
129
129
|
onToggle,
|
|
130
130
|
runs,
|
|
131
|
+
reference,
|
|
131
132
|
}: {
|
|
132
133
|
provided: RpcService;
|
|
133
134
|
/** Which shapes are expanded, keyed `<interface>/<method>/req|res`. */
|
|
@@ -135,6 +136,8 @@ export function MethodRows({
|
|
|
135
136
|
onToggle: (key: string) => void;
|
|
136
137
|
/** What the method runs, when the page knows — the service page does. */
|
|
137
138
|
runs?: (method: RpcMethod) => ReactNode;
|
|
139
|
+
/** Protocol-specific tools shown below the method, when its reference has one. */
|
|
140
|
+
reference?: (method: RpcMethod) => ReactNode;
|
|
138
141
|
}) {
|
|
139
142
|
return (
|
|
140
143
|
<ul data-nav-list>
|
|
@@ -227,6 +230,8 @@ export function MethodRows({
|
|
|
227
230
|
|
|
228
231
|
{runs?.(method)}
|
|
229
232
|
|
|
233
|
+
{reference?.(method)}
|
|
234
|
+
|
|
230
235
|
{open.has(reqKey) && reqFields ? (
|
|
231
236
|
<div className="mt-2">
|
|
232
237
|
<DiscriminatorRows discriminator={reqDiscriminator} />
|
|
@@ -240,7 +245,9 @@ export function MethodRows({
|
|
|
240
245
|
</div>
|
|
241
246
|
) : null}
|
|
242
247
|
</div>
|
|
243
|
-
<
|
|
248
|
+
<span className="self-start pt-0.5">
|
|
249
|
+
<RowActions copy={id} />
|
|
250
|
+
</span>
|
|
244
251
|
</li>
|
|
245
252
|
);
|
|
246
253
|
})}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { CapabilityEmpty } from "./PageHeader";
|
|
6
|
+
|
|
7
|
+
describe("CapabilityEmpty", () => {
|
|
8
|
+
it("keeps the explanation, action and evidence signal together", () => {
|
|
9
|
+
const markup = renderToStaticMarkup(createElement(
|
|
10
|
+
CapabilityEmpty,
|
|
11
|
+
{
|
|
12
|
+
title: "No dependency graph yet",
|
|
13
|
+
actions: createElement("button", { type: "button" }, "Review extraction"),
|
|
14
|
+
signal: "Signals: domain events and consumers.",
|
|
15
|
+
},
|
|
16
|
+
"The catalog has components, but no events to connect.",
|
|
17
|
+
));
|
|
18
|
+
|
|
19
|
+
expect(markup).toContain("next useful step");
|
|
20
|
+
expect(markup).toContain("No dependency graph yet");
|
|
21
|
+
expect(markup).toContain("Review extraction");
|
|
22
|
+
expect(markup).toContain("Signals: domain events and consumers.");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -155,6 +155,40 @@ export function Empty({ children }: { children: ReactNode }) {
|
|
|
155
155
|
return <div className="empty">{children}</div>;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* A page-level absence with a useful next move.
|
|
160
|
+
*
|
|
161
|
+
* Unlike `Empty`, this is not the result of a filter, and unlike `Blank` it
|
|
162
|
+
* does more than document the catalog field that is missing. It is used when
|
|
163
|
+
* the reader can take a concrete setup action to make the page useful.
|
|
164
|
+
*/
|
|
165
|
+
export function CapabilityEmpty({
|
|
166
|
+
title,
|
|
167
|
+
children,
|
|
168
|
+
actions,
|
|
169
|
+
signal,
|
|
170
|
+
}: {
|
|
171
|
+
title: string;
|
|
172
|
+
children: ReactNode;
|
|
173
|
+
actions?: ReactNode;
|
|
174
|
+
/** The source evidence or catalog fact that will fill this page. */
|
|
175
|
+
signal?: ReactNode;
|
|
176
|
+
}) {
|
|
177
|
+
return (
|
|
178
|
+
<div className="rounded-card border border-accent/60 bg-surface p-5 shadow-xs">
|
|
179
|
+
<div className="label text-accent">next useful step</div>
|
|
180
|
+
<h2 className="mt-1 text-md font-semibold text-ink">{title}</h2>
|
|
181
|
+
<div className="mt-2 max-w-prose text-muted">{children}</div>
|
|
182
|
+
{actions ? <div className="mt-4 flex flex-wrap gap-2">{actions}</div> : null}
|
|
183
|
+
{signal ? (
|
|
184
|
+
<div className="mono mt-4 border-t border-line pt-3 text-faint">
|
|
185
|
+
{signal}
|
|
186
|
+
</div>
|
|
187
|
+
) : null}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
/**
|
|
159
193
|
* What a page says before the catalog has anything to put on it.
|
|
160
194
|
*
|
|
@@ -44,10 +44,13 @@ function EnumRows({ set }: { set: RpcEnum }) {
|
|
|
44
44
|
export function ShapeRows({
|
|
45
45
|
fields,
|
|
46
46
|
enums,
|
|
47
|
+
showHeader = false,
|
|
47
48
|
}: {
|
|
48
49
|
fields: Field[];
|
|
49
50
|
/** The interface's enums, so a field typed by one can open its values. */
|
|
50
51
|
enums?: RpcEnum[];
|
|
52
|
+
/** A contract panel needs named columns; compact inline shapes do not. */
|
|
53
|
+
showHeader?: boolean;
|
|
51
54
|
}) {
|
|
52
55
|
const [open, setOpen] = useState<Set<string>>(new Set());
|
|
53
56
|
const toggle = (name: string) =>
|
|
@@ -60,6 +63,15 @@ export function ShapeRows({
|
|
|
60
63
|
|
|
61
64
|
return (
|
|
62
65
|
<table className="w-full">
|
|
66
|
+
{showHeader ? (
|
|
67
|
+
<thead>
|
|
68
|
+
<tr className="label border-b border-line text-left">
|
|
69
|
+
<th className="pb-1 pr-3 font-normal">Field</th>
|
|
70
|
+
<th className="pb-1 pr-3 font-normal">Type</th>
|
|
71
|
+
<th className="pb-1 font-normal">Description</th>
|
|
72
|
+
</tr>
|
|
73
|
+
</thead>
|
|
74
|
+
) : null}
|
|
63
75
|
<tbody>
|
|
64
76
|
{fields.map((field) => {
|
|
65
77
|
const set = enumFor(enums, field);
|
|
@@ -20,6 +20,7 @@ import { KindIcon } from "./kind";
|
|
|
20
20
|
import { MessageList, MethodRows } from "./MethodRows";
|
|
21
21
|
import { paths } from "../routes";
|
|
22
22
|
import { packageOf } from "../lib/registry";
|
|
23
|
+
import { GrpcMethodReference } from "./GrpcMethodReference";
|
|
23
24
|
|
|
24
25
|
/** The interfaces of one module, as the service page's spec tab shows them. */
|
|
25
26
|
export function ModuleSpec({ moduleId }: { moduleId: string }) {
|
|
@@ -46,17 +47,24 @@ export function ModuleSpec({ moduleId }: { moduleId: string }) {
|
|
|
46
47
|
|
|
47
48
|
return (
|
|
48
49
|
<div className="max-w-table">
|
|
49
|
-
<div className="
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
<div className="rounded-card border border-line bg-surface px-4 py-3">
|
|
51
|
+
<div className="mono flex flex-wrap items-center gap-x-3">
|
|
52
|
+
<KindIcon kind="module" />
|
|
53
|
+
<span className="chip">gRPC</span>
|
|
54
|
+
<Link
|
|
55
|
+
to={paths.module(module.slug)}
|
|
56
|
+
className="text-ink hover:underline"
|
|
57
|
+
>
|
|
58
|
+
{module.name}
|
|
59
|
+
</Link>
|
|
60
|
+
{module.commit ? (
|
|
61
|
+
<Ident value={module.commit.slice(0, 12)} className="text-muted" />
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
<p className="mt-1.5 text-muted">
|
|
65
|
+
Methods and message shapes extracted from Protocol Buffers. Open a
|
|
66
|
+
method's local call to copy a ProtoJSON body or a grpcurl command.
|
|
67
|
+
</p>
|
|
60
68
|
</div>
|
|
61
69
|
|
|
62
70
|
{declared.length === 0 ? (
|
|
@@ -72,7 +80,14 @@ export function ModuleSpec({ moduleId }: { moduleId: string }) {
|
|
|
72
80
|
<div className="mono border-b px-3 py-1.5 border-line bg-surface">
|
|
73
81
|
<Ident value={provided.id} className="text-ink" />
|
|
74
82
|
</div>
|
|
75
|
-
<MethodRows
|
|
83
|
+
<MethodRows
|
|
84
|
+
provided={provided}
|
|
85
|
+
open={open}
|
|
86
|
+
onToggle={toggle}
|
|
87
|
+
reference={(method) => (
|
|
88
|
+
<GrpcMethodReference provided={provided} method={method} />
|
|
89
|
+
)}
|
|
90
|
+
/>
|
|
76
91
|
<MessageList provided={provided} open={open} onToggle={toggle} />
|
|
77
92
|
</div>
|
|
78
93
|
</section>
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
Pin,
|
|
7
7
|
X,
|
|
8
8
|
} from "lucide-react";
|
|
9
|
-
import { EditorLink } from "./EditorLink";
|
|
9
|
+
import { EditorLink, useEditorTarget } from "./EditorLink";
|
|
10
10
|
import {
|
|
11
11
|
useCallback,
|
|
12
12
|
useEffect,
|
|
@@ -140,7 +140,8 @@ function CodeWindow({
|
|
|
140
140
|
[file.content, location.line],
|
|
141
141
|
);
|
|
142
142
|
const highlighted = useMemo(
|
|
143
|
-
() =>
|
|
143
|
+
() =>
|
|
144
|
+
highlightSource(location.path, rows.map((row) => row.text).join("\n")),
|
|
144
145
|
[location.path, rows],
|
|
145
146
|
);
|
|
146
147
|
const focusedIndex = rows.findIndex((row) => row.focused);
|
|
@@ -149,7 +150,9 @@ function CodeWindow({
|
|
|
149
150
|
if (node.type === "text") return node.value;
|
|
150
151
|
const names = node.properties?.className;
|
|
151
152
|
const className = Array.isArray(names)
|
|
152
|
-
? names
|
|
153
|
+
? names
|
|
154
|
+
.filter((name): name is string => typeof name === "string")
|
|
155
|
+
.join(" ")
|
|
153
156
|
: typeof names === "string"
|
|
154
157
|
? names
|
|
155
158
|
: undefined;
|
|
@@ -187,7 +190,9 @@ function CodeWindow({
|
|
|
187
190
|
))}
|
|
188
191
|
</div>
|
|
189
192
|
<pre className="source-code relative z-10 min-w-max pl-3 pr-4 text-ink">
|
|
190
|
-
<code>
|
|
193
|
+
<code>
|
|
194
|
+
{highlighted.map((node, index) => renderNode(node, String(index)))}
|
|
195
|
+
</code>
|
|
191
196
|
</pre>
|
|
192
197
|
</div>
|
|
193
198
|
</div>
|
|
@@ -315,18 +320,25 @@ function PreviewPanel({
|
|
|
315
320
|
);
|
|
316
321
|
}
|
|
317
322
|
|
|
318
|
-
|
|
319
|
-
export function SourcePreviewButton({
|
|
323
|
+
function SourcePreviewTrigger({
|
|
320
324
|
location,
|
|
325
|
+
children,
|
|
321
326
|
className = "",
|
|
327
|
+
href,
|
|
328
|
+
external = false,
|
|
329
|
+
title,
|
|
322
330
|
}: {
|
|
323
|
-
location: SourceLocation
|
|
331
|
+
location: SourceLocation;
|
|
332
|
+
children: ReactNode;
|
|
324
333
|
className?: string;
|
|
334
|
+
href?: string;
|
|
335
|
+
external?: boolean;
|
|
336
|
+
title: string;
|
|
325
337
|
}) {
|
|
326
338
|
const [open, setOpen] = useState(false);
|
|
327
339
|
const [pinned, setPinned] = useState(false);
|
|
328
340
|
const [position, setPosition] = useState<Position | null>(null);
|
|
329
|
-
const triggerRef = useRef<
|
|
341
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
330
342
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
331
343
|
const pinnedRef = useRef(false);
|
|
332
344
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -386,41 +398,59 @@ export function SourcePreviewButton({
|
|
|
386
398
|
return () => document.removeEventListener("pointerdown", outside);
|
|
387
399
|
}, [close, pinned]);
|
|
388
400
|
|
|
389
|
-
if (!location) return null;
|
|
390
|
-
|
|
391
401
|
const hover = (event: ReactPointerEvent) => {
|
|
392
402
|
if (event.pointerType === "mouse" || event.pointerType === "pen")
|
|
393
403
|
openSoon();
|
|
394
404
|
};
|
|
395
405
|
|
|
406
|
+
const triggerProps = {
|
|
407
|
+
"aria-haspopup": "dialog" as const,
|
|
408
|
+
"aria-expanded": open,
|
|
409
|
+
"aria-controls": open ? id : undefined,
|
|
410
|
+
className: `mono inline-flex items-center gap-1 rounded-control text-accent hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent ${className}`,
|
|
411
|
+
title,
|
|
412
|
+
onPointerEnter: hover,
|
|
413
|
+
onPointerLeave: closeSoon,
|
|
414
|
+
onFocus: () => setOpen(true),
|
|
415
|
+
onBlur: closeSoon,
|
|
416
|
+
};
|
|
417
|
+
|
|
396
418
|
return (
|
|
397
419
|
<>
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
420
|
+
{href ? (
|
|
421
|
+
<a
|
|
422
|
+
ref={(node) => {
|
|
423
|
+
triggerRef.current = node;
|
|
424
|
+
}}
|
|
425
|
+
href={href}
|
|
426
|
+
target={external ? "_blank" : undefined}
|
|
427
|
+
rel={external ? "noreferrer" : undefined}
|
|
428
|
+
{...triggerProps}
|
|
429
|
+
>
|
|
430
|
+
{children}
|
|
431
|
+
</a>
|
|
432
|
+
) : (
|
|
433
|
+
<button
|
|
434
|
+
ref={(node) => {
|
|
435
|
+
triggerRef.current = node;
|
|
436
|
+
}}
|
|
437
|
+
type="button"
|
|
438
|
+
{...triggerProps}
|
|
439
|
+
onClick={() => {
|
|
440
|
+
cancelTimer();
|
|
441
|
+
if (open && pinned) {
|
|
442
|
+
close();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
setOpen(true);
|
|
446
|
+
pinnedRef.current = true;
|
|
447
|
+
setPinned(true);
|
|
448
|
+
setTimeout(() => panelRef.current?.focus(), 0);
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
{children}
|
|
452
|
+
</button>
|
|
453
|
+
)}
|
|
424
454
|
{open && position && typeof document !== "undefined"
|
|
425
455
|
? createPortal(
|
|
426
456
|
<PreviewPanel
|
|
@@ -439,3 +469,61 @@ export function SourcePreviewButton({
|
|
|
439
469
|
</>
|
|
440
470
|
);
|
|
441
471
|
}
|
|
472
|
+
|
|
473
|
+
/** Hover/focus for a glance; click pins the same preview for scrolling and copying. */
|
|
474
|
+
export function SourcePreviewButton({
|
|
475
|
+
location,
|
|
476
|
+
className = "",
|
|
477
|
+
}: {
|
|
478
|
+
location: SourceLocation | null;
|
|
479
|
+
className?: string;
|
|
480
|
+
}) {
|
|
481
|
+
if (!location) return null;
|
|
482
|
+
return (
|
|
483
|
+
<SourcePreviewTrigger
|
|
484
|
+
location={location}
|
|
485
|
+
className={className}
|
|
486
|
+
title="Preview source · click to pin"
|
|
487
|
+
>
|
|
488
|
+
<Code2 size={13} aria-hidden /> preview
|
|
489
|
+
</SourcePreviewTrigger>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* A compact source path: preview on hover, then open the configured editor in
|
|
495
|
+
* local mode or the immutable forge location in a static build.
|
|
496
|
+
*/
|
|
497
|
+
export function SourcePreviewLink({
|
|
498
|
+
location,
|
|
499
|
+
children,
|
|
500
|
+
className = "",
|
|
501
|
+
}: {
|
|
502
|
+
location: SourceLocation | null;
|
|
503
|
+
children: ReactNode;
|
|
504
|
+
className?: string;
|
|
505
|
+
}) {
|
|
506
|
+
const editor = useEditorTarget(location);
|
|
507
|
+
if (!location) return <span className={className}>{children}</span>;
|
|
508
|
+
const href = editor?.href ?? location.href ?? undefined;
|
|
509
|
+
const destination = editor
|
|
510
|
+
? editor.name
|
|
511
|
+
: location.href
|
|
512
|
+
? "repository"
|
|
513
|
+
: null;
|
|
514
|
+
return (
|
|
515
|
+
<SourcePreviewTrigger
|
|
516
|
+
location={location}
|
|
517
|
+
className={className}
|
|
518
|
+
href={href}
|
|
519
|
+
external={!editor && Boolean(location.href)}
|
|
520
|
+
title={
|
|
521
|
+
destination
|
|
522
|
+
? `Preview on hover · click to open in ${destination}`
|
|
523
|
+
: "Preview source · click to pin"
|
|
524
|
+
}
|
|
525
|
+
>
|
|
526
|
+
{children}
|
|
527
|
+
</SourcePreviewTrigger>
|
|
528
|
+
);
|
|
529
|
+
}
|
package/src/enrich.test.ts
CHANGED
|
@@ -88,7 +88,14 @@ function pricing(): Service {
|
|
|
88
88
|
provides: [
|
|
89
89
|
{
|
|
90
90
|
id: "pricing.v1.Pricing",
|
|
91
|
-
methods: [
|
|
91
|
+
methods: [
|
|
92
|
+
{
|
|
93
|
+
name: "Quote",
|
|
94
|
+
doc: "",
|
|
95
|
+
request: "QuoteRequest",
|
|
96
|
+
response: "QuoteResponse",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
92
99
|
source: "pricing.proto",
|
|
93
100
|
},
|
|
94
101
|
],
|
|
@@ -113,7 +120,7 @@ beforeEach(() => {
|
|
|
113
120
|
function step(
|
|
114
121
|
from: string,
|
|
115
122
|
to: string,
|
|
116
|
-
kind: "rpc" | "event" | "call",
|
|
123
|
+
kind: "rpc" | "event" | "call" | "response",
|
|
117
124
|
extra: Partial<Extract<FlowNode, { type: "step" }>> = {},
|
|
118
125
|
): FlowNode {
|
|
119
126
|
n += 1;
|
|
@@ -287,6 +294,7 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
|
|
|
287
294
|
"enqueue void",
|
|
288
295
|
"VoidWorker.Work",
|
|
289
296
|
"POST /cancel",
|
|
297
|
+
"HTTP response",
|
|
290
298
|
]);
|
|
291
299
|
expect(root.includes).toEqual(["void-job", "void-http"]);
|
|
292
300
|
expect(once.flows.map((item) => item.slug)).toEqual([
|
|
@@ -345,6 +353,7 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
|
|
|
345
353
|
"POST /invoices/{id}/issue",
|
|
346
354
|
"enqueue send_invoice_email",
|
|
347
355
|
"send_invoice_email",
|
|
356
|
+
"HTTP response",
|
|
348
357
|
]);
|
|
349
358
|
expect(root.includes).toEqual(["send-invoice-email"]);
|
|
350
359
|
expect(enrichCatalog(result).catalog).toEqual(result);
|
|
@@ -404,6 +413,127 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
|
|
|
404
413
|
expect(enrichCatalog(once).catalog).toEqual(once);
|
|
405
414
|
});
|
|
406
415
|
|
|
416
|
+
it("synthesizes returns for nested unary rpc and the HTTP root", () => {
|
|
417
|
+
const entrypoint = "pricing/rpc:Server.Quote";
|
|
418
|
+
const api = {
|
|
419
|
+
...flow("quote-api", [
|
|
420
|
+
step("client", "shop.oms", "rpc", { label: "GET /quote" }),
|
|
421
|
+
step("shop.oms", "shop.pricing", "rpc", {
|
|
422
|
+
ref: METHOD,
|
|
423
|
+
label: "Quote",
|
|
424
|
+
continuesAt: entrypoint,
|
|
425
|
+
}),
|
|
426
|
+
]),
|
|
427
|
+
trigger: {
|
|
428
|
+
kind: "http",
|
|
429
|
+
label: "GET /quote",
|
|
430
|
+
confidence: "high",
|
|
431
|
+
} as const,
|
|
432
|
+
};
|
|
433
|
+
const provider = {
|
|
434
|
+
...flow("quote-rpc", [
|
|
435
|
+
step("shop.pricing", "shop.pricing", "call", {
|
|
436
|
+
label: "load quote",
|
|
437
|
+
}),
|
|
438
|
+
]),
|
|
439
|
+
entrypoint,
|
|
440
|
+
trigger: {
|
|
441
|
+
kind: "unproven",
|
|
442
|
+
label: METHOD,
|
|
443
|
+
confidence: "high",
|
|
444
|
+
} as const,
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const once = enrichCatalog(estate([api, provider])).catalog;
|
|
448
|
+
const root = once.flows.find((item) => item.slug === "quote-api")!;
|
|
449
|
+
const steps = walkSteps(root.steps);
|
|
450
|
+
|
|
451
|
+
expect(
|
|
452
|
+
steps.map((item) => [item.kind, item.from, item.to, item.label]),
|
|
453
|
+
).toEqual([
|
|
454
|
+
["rpc", "client", "shop.oms", "GET /quote"],
|
|
455
|
+
["rpc", "shop.oms", "shop.pricing", "Quote"],
|
|
456
|
+
["call", "shop.pricing", "shop.pricing", "load quote"],
|
|
457
|
+
["response", "shop.pricing", "shop.oms", "QuoteResponse"],
|
|
458
|
+
["response", "shop.oms", "client", "HTTP response"],
|
|
459
|
+
]);
|
|
460
|
+
expect(steps[3]!.replyTo).toBe(steps[1]!.id);
|
|
461
|
+
expect(steps[4]!.replyTo).toBe(steps[0]!.id);
|
|
462
|
+
expect(enrichCatalog(once).catalog).toEqual(once);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("hydrates a source-proven HTTP body from the serialized rpc response", () => {
|
|
466
|
+
const api = {
|
|
467
|
+
...flow("quote-api", [
|
|
468
|
+
step("client", "shop.oms", "rpc", { label: "GET /quote" }),
|
|
469
|
+
step("shop.oms", "shop.pricing", "rpc", {
|
|
470
|
+
ref: METHOD,
|
|
471
|
+
label: "Quote",
|
|
472
|
+
}),
|
|
473
|
+
step("shop.oms", "client", "response", {
|
|
474
|
+
label: "200 · HTTP response",
|
|
475
|
+
replyTo: "s1",
|
|
476
|
+
http: {
|
|
477
|
+
status: 200,
|
|
478
|
+
contentType: "application/json",
|
|
479
|
+
bodyRef: METHOD,
|
|
480
|
+
encoding: "protojson",
|
|
481
|
+
outcome: "success",
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
]),
|
|
485
|
+
trigger: {
|
|
486
|
+
kind: "http",
|
|
487
|
+
label: "GET /quote",
|
|
488
|
+
confidence: "high",
|
|
489
|
+
} as const,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const once = enrichCatalog(estate([api])).catalog;
|
|
493
|
+
const response = walkSteps(once.flows[0]!.steps).at(-1)!;
|
|
494
|
+
|
|
495
|
+
expect(response.label).toBe("200 · QuoteResponse");
|
|
496
|
+
expect(response.http?.body).toBe("QuoteResponse");
|
|
497
|
+
expect(response.http?.bodyRef).toBe(METHOD);
|
|
498
|
+
expect(enrichCatalog(once).catalog).toEqual(once);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("does not synthesize a unary return for a streaming rpc", () => {
|
|
502
|
+
const entrypoint = "pricing/rpc:Server.Watch";
|
|
503
|
+
const api = {
|
|
504
|
+
...flow("watch-api", [
|
|
505
|
+
step("client", "shop.oms", "rpc", { label: "GET /watch" }),
|
|
506
|
+
step("shop.oms", "shop.pricing", "rpc", {
|
|
507
|
+
ref: METHOD,
|
|
508
|
+
label: "Quote",
|
|
509
|
+
continuesAt: entrypoint,
|
|
510
|
+
}),
|
|
511
|
+
]),
|
|
512
|
+
trigger: {
|
|
513
|
+
kind: "http",
|
|
514
|
+
label: "GET /watch",
|
|
515
|
+
confidence: "high",
|
|
516
|
+
} as const,
|
|
517
|
+
};
|
|
518
|
+
const provider = {
|
|
519
|
+
...flow("watch-rpc", [
|
|
520
|
+
step("shop.pricing", "shop.pricing", "call", { label: "watch" }),
|
|
521
|
+
]),
|
|
522
|
+
entrypoint,
|
|
523
|
+
};
|
|
524
|
+
const streaming = pricing();
|
|
525
|
+
streaming.provides[0]!.methods[0]!.streaming = "server";
|
|
526
|
+
|
|
527
|
+
const result = enrichCatalog(
|
|
528
|
+
estate([api, provider], [oms(), streaming]),
|
|
529
|
+
).catalog;
|
|
530
|
+
const responses = walkSteps(result.flows[0]!.steps).filter(
|
|
531
|
+
(item) => item.kind === "response",
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
expect(responses.map((item) => item.label)).toEqual(["HTTP response"]);
|
|
535
|
+
});
|
|
536
|
+
|
|
407
537
|
it("refuses an ambiguous message handoff", () => {
|
|
408
538
|
const publisher = flow("publisher", [
|
|
409
539
|
step("shop.oms", "bus", "event", {
|
|
@@ -892,6 +1022,83 @@ describe("enrichCatalog: invariants", () => {
|
|
|
892
1022
|
});
|
|
893
1023
|
});
|
|
894
1024
|
|
|
1025
|
+
describe("enrichCatalog: Redis store accesses", () => {
|
|
1026
|
+
it("joins a repository call to one concrete Redis key access", () => {
|
|
1027
|
+
const c = estate([
|
|
1028
|
+
flow("load-order", [
|
|
1029
|
+
step("shop.oms", "oms-db", "call", {
|
|
1030
|
+
label: "Get",
|
|
1031
|
+
storeAccess: { store: "shop.oms.redis", method: "Get" },
|
|
1032
|
+
}),
|
|
1033
|
+
]),
|
|
1034
|
+
]);
|
|
1035
|
+
c.stores = [
|
|
1036
|
+
{
|
|
1037
|
+
id: "shop.oms.redis",
|
|
1038
|
+
slug: "redis",
|
|
1039
|
+
name: "OMS Redis",
|
|
1040
|
+
kind: "redis",
|
|
1041
|
+
owner: "shop.oms",
|
|
1042
|
+
tables: [],
|
|
1043
|
+
keyspaces: [
|
|
1044
|
+
{
|
|
1045
|
+
pattern: "order:{id}",
|
|
1046
|
+
operations: ["read"],
|
|
1047
|
+
accesses: [
|
|
1048
|
+
{
|
|
1049
|
+
operation: "read",
|
|
1050
|
+
method: "Store.Get",
|
|
1051
|
+
source: "repository/redis.go:20",
|
|
1052
|
+
},
|
|
1053
|
+
],
|
|
1054
|
+
},
|
|
1055
|
+
],
|
|
1056
|
+
},
|
|
1057
|
+
];
|
|
1058
|
+
|
|
1059
|
+
const result = enrichCatalog(c).catalog;
|
|
1060
|
+
expect(walkSteps(result.flows[0]!.steps)[0]!.storeAccess).toEqual({
|
|
1061
|
+
store: "shop.oms.redis",
|
|
1062
|
+
method: "Get",
|
|
1063
|
+
operation: "read",
|
|
1064
|
+
keyspace: "order:{id}",
|
|
1065
|
+
source: "repository/redis.go:20",
|
|
1066
|
+
});
|
|
1067
|
+
expect(enrichCatalog(result).catalog).toBe(result);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("leaves an ambiguous repository method unresolved", () => {
|
|
1071
|
+
const c = estate([
|
|
1072
|
+
flow("load-order", [
|
|
1073
|
+
step("shop.oms", "oms-db", "call", {
|
|
1074
|
+
storeAccess: { store: "shop.oms.redis", method: "Get" },
|
|
1075
|
+
}),
|
|
1076
|
+
]),
|
|
1077
|
+
]);
|
|
1078
|
+
c.stores = [
|
|
1079
|
+
{
|
|
1080
|
+
id: "shop.oms.redis",
|
|
1081
|
+
slug: "redis",
|
|
1082
|
+
name: "OMS Redis",
|
|
1083
|
+
kind: "redis",
|
|
1084
|
+
owner: "shop.oms",
|
|
1085
|
+
tables: [],
|
|
1086
|
+
keyspaces: ["order:{id}", "order-by-number:{number}"].map(
|
|
1087
|
+
(pattern) => ({
|
|
1088
|
+
pattern,
|
|
1089
|
+
operations: ["read" as const],
|
|
1090
|
+
accesses: [{ operation: "read" as const, method: "Store.Get" }],
|
|
1091
|
+
}),
|
|
1092
|
+
),
|
|
1093
|
+
},
|
|
1094
|
+
];
|
|
1095
|
+
|
|
1096
|
+
expect(
|
|
1097
|
+
walkSteps(enrichCatalog(c).catalog.flows[0]!.steps)[0]!.storeAccess,
|
|
1098
|
+
).toEqual({ store: "shop.oms.redis", method: "Get" });
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
|
|
895
1102
|
describe("enrichCatalog: the auth fragment", () => {
|
|
896
1103
|
// The three fragments together, the way the app reads them: the domain one
|
|
897
1104
|
// alone names endpoints the api one declares.
|