@shortlink-org/portolan 0.2.1 → 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/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- 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/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- 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 +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- 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/app/toast.tsx +11 -7
- 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 +49 -0
- package/src/catalog.ts +6 -3124
- 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.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- 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 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- 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/derive.ts +1 -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 +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- 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 +101 -0
- package/src/merge.ts +65 -1
- 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 +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
package/src/flow/StepRail.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useEffect, useId, useRef } from "react";
|
|
|
2
2
|
import { Link } from "react-router";
|
|
3
3
|
import { LayoutGroup, m, transitions } from "../lib/motion";
|
|
4
4
|
import {
|
|
5
|
+
AlertCircle,
|
|
5
6
|
ArrowRight,
|
|
6
7
|
ChevronDown,
|
|
7
8
|
ChevronRight,
|
|
@@ -13,13 +14,11 @@ import { STATUSES } from "../catalog";
|
|
|
13
14
|
import { contextName, ctxStyle } from "../lib/context-color";
|
|
14
15
|
import { paths } from "../routes";
|
|
15
16
|
import { statusVar } from "../components/primitives";
|
|
16
|
-
import { railRows } from "./chapters";
|
|
17
|
+
import { railRows, stepTitle } from "./chapters";
|
|
17
18
|
import type { Chapter, ChapterGroup } from "./chapters";
|
|
18
19
|
import type { Continuation } from "./continues";
|
|
19
20
|
import type { OutlineFrame, OutlineStep } from "./outline";
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
22
|
/**
|
|
24
23
|
* A chapter header. The one row on the rail that is a control rather than a
|
|
25
24
|
* reading: it folds its own steps away and nothing else — the canvas keeps
|
|
@@ -68,7 +67,10 @@ function ChapterRow({
|
|
|
68
67
|
{chapter.kind}
|
|
69
68
|
</span>
|
|
70
69
|
)}
|
|
71
|
-
<span
|
|
70
|
+
<span
|
|
71
|
+
className="mono min-w-0 flex-1 truncate text-ink"
|
|
72
|
+
title={chapter.title}
|
|
73
|
+
>
|
|
72
74
|
{chapter.title}
|
|
73
75
|
</span>
|
|
74
76
|
{/* The contexts this episode touches, as colour and nothing else: the
|
|
@@ -89,7 +91,11 @@ function ChapterRow({
|
|
|
89
91
|
<span className="mono flex shrink-0 items-center gap-1 text-muted">
|
|
90
92
|
{STATUSES.filter((status) => chapter.status[status] > 0).map(
|
|
91
93
|
(status) => (
|
|
92
|
-
<span
|
|
94
|
+
<span
|
|
95
|
+
key={status}
|
|
96
|
+
className="flex items-center gap-0.5"
|
|
97
|
+
title={status}
|
|
98
|
+
>
|
|
93
99
|
<span
|
|
94
100
|
aria-hidden
|
|
95
101
|
className="size-1.5 rounded-[1px]"
|
|
@@ -168,7 +174,9 @@ function CrossChip({ step, context }: { step: Step; context: string | null }) {
|
|
|
168
174
|
return (
|
|
169
175
|
<span
|
|
170
176
|
className="chip shrink-0 ctx"
|
|
171
|
-
style={
|
|
177
|
+
style={
|
|
178
|
+
context ? ctxStyle(context) : { borderColor: "var(--border-strong)" }
|
|
179
|
+
}
|
|
172
180
|
title={`crosses into ${label}`}
|
|
173
181
|
>
|
|
174
182
|
→ {label}
|
|
@@ -202,6 +210,7 @@ function StepRow({
|
|
|
202
210
|
const { step, number, depth, hidden, offPath, offStatus } = row;
|
|
203
211
|
const self = step.from === step.to;
|
|
204
212
|
const crosses = crossContext !== undefined;
|
|
213
|
+
const errorResponse = step.http?.outcome === "error";
|
|
205
214
|
return (
|
|
206
215
|
<div
|
|
207
216
|
onMouseEnter={() => onHover(step.id)}
|
|
@@ -226,8 +235,11 @@ function StepRow({
|
|
|
226
235
|
style={{
|
|
227
236
|
borderLeftWidth: 2,
|
|
228
237
|
borderLeftStyle: "solid",
|
|
229
|
-
borderLeftColor:
|
|
238
|
+
borderLeftColor: errorResponse
|
|
239
|
+
? "var(--response-error)"
|
|
240
|
+
: "transparent",
|
|
230
241
|
paddingLeft: 8 + depth * 10,
|
|
242
|
+
background: errorResponse ? "var(--response-error-bg)" : undefined,
|
|
231
243
|
}}
|
|
232
244
|
aria-current={active ? "true" : undefined}
|
|
233
245
|
>
|
|
@@ -251,14 +263,17 @@ function StepRow({
|
|
|
251
263
|
a character. Both sit on their content and the answer, weighted
|
|
252
264
|
to give way ten times faster, is the one that loses. */}
|
|
253
265
|
<span
|
|
254
|
-
className="min-w-0 truncate
|
|
255
|
-
|
|
266
|
+
className="min-w-0 truncate"
|
|
267
|
+
style={{
|
|
268
|
+
color: errorResponse ? "var(--response-error)" : "var(--fg)",
|
|
269
|
+
}}
|
|
270
|
+
title={stepTitle(step)}
|
|
256
271
|
>
|
|
257
|
-
{step
|
|
272
|
+
{stepTitle(step)}
|
|
258
273
|
</span>
|
|
259
|
-
{/*
|
|
260
|
-
|
|
261
|
-
|
|
274
|
+
{/* A standalone call keeps its contract answer on this line. Once
|
|
275
|
+
composition proves the nested return, the answer becomes its
|
|
276
|
+
own response step and is omitted from this map. */}
|
|
262
277
|
{/* Only where there is room for it: two truncated halves read
|
|
263
278
|
worse than one whole label, so on a rail dragged narrow the
|
|
264
279
|
answer steps aside and the step's own panel still says it. */}
|
|
@@ -291,6 +306,14 @@ function StepRow({
|
|
|
291
306
|
{full ? (
|
|
292
307
|
<span className="mono shrink-0 text-muted">{step.kind}</span>
|
|
293
308
|
) : null}
|
|
309
|
+
{errorResponse ? (
|
|
310
|
+
<AlertCircle
|
|
311
|
+
size={12}
|
|
312
|
+
aria-label="error response"
|
|
313
|
+
className="mt-0.5 shrink-0"
|
|
314
|
+
style={{ color: "var(--response-error)" }}
|
|
315
|
+
/>
|
|
316
|
+
) : null}
|
|
294
317
|
{crosses ? <CrossChip step={step} context={crossContext} /> : null}
|
|
295
318
|
{hidden ? (
|
|
296
319
|
<span
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildIndex } from "../catalog";
|
|
4
|
+
import type { Catalog, Flow } from "../catalog";
|
|
5
|
+
import { flowAnswers, stepAnswer } from "./answers";
|
|
6
|
+
|
|
7
|
+
describe("explicit flow responses", () => {
|
|
8
|
+
it("moves the contract answer off a request that has a response step", () => {
|
|
9
|
+
const flow: Flow = {
|
|
10
|
+
id: "flow.get-book",
|
|
11
|
+
slug: "get-book",
|
|
12
|
+
name: "Get book",
|
|
13
|
+
summary: "",
|
|
14
|
+
owner: "demo",
|
|
15
|
+
participants: [
|
|
16
|
+
{ id: "demo.api", kind: "service", context: "demo" },
|
|
17
|
+
{ id: "demo.book", kind: "service", context: "demo" },
|
|
18
|
+
],
|
|
19
|
+
steps: [
|
|
20
|
+
{
|
|
21
|
+
type: "step",
|
|
22
|
+
id: "request",
|
|
23
|
+
from: "demo.api",
|
|
24
|
+
to: "demo.book",
|
|
25
|
+
kind: "rpc",
|
|
26
|
+
ref: "book.v1.Book/Get",
|
|
27
|
+
label: "Get",
|
|
28
|
+
status: "declared",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: "step",
|
|
32
|
+
id: "response",
|
|
33
|
+
from: "demo.book",
|
|
34
|
+
to: "demo.api",
|
|
35
|
+
kind: "response",
|
|
36
|
+
label: "GetResponse",
|
|
37
|
+
status: "declared",
|
|
38
|
+
replyTo: "request",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
const catalog: Catalog = {
|
|
43
|
+
generatedAt: "2026-09-09T00:00:00Z",
|
|
44
|
+
commit: "0000000",
|
|
45
|
+
contexts: [
|
|
46
|
+
{
|
|
47
|
+
id: "demo",
|
|
48
|
+
slug: "demo",
|
|
49
|
+
name: "Demo",
|
|
50
|
+
summary: "",
|
|
51
|
+
services: [
|
|
52
|
+
{
|
|
53
|
+
id: "demo.api",
|
|
54
|
+
slug: "api",
|
|
55
|
+
name: "API",
|
|
56
|
+
repo: "",
|
|
57
|
+
path: "",
|
|
58
|
+
readme: "",
|
|
59
|
+
provides: [],
|
|
60
|
+
consumes: [],
|
|
61
|
+
aggregates: [],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "demo.book",
|
|
65
|
+
slug: "book",
|
|
66
|
+
name: "Book",
|
|
67
|
+
repo: "",
|
|
68
|
+
path: "",
|
|
69
|
+
readme: "",
|
|
70
|
+
provides: [
|
|
71
|
+
{
|
|
72
|
+
id: "book.v1.Book",
|
|
73
|
+
source: "book.proto",
|
|
74
|
+
methods: [
|
|
75
|
+
{
|
|
76
|
+
name: "Get",
|
|
77
|
+
request: "GetRequest",
|
|
78
|
+
response: "GetResponse",
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
consumes: [],
|
|
84
|
+
aggregates: [],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
defs: {},
|
|
90
|
+
flows: [flow],
|
|
91
|
+
adrs: [],
|
|
92
|
+
};
|
|
93
|
+
const index = buildIndex(catalog);
|
|
94
|
+
const request = flow.steps[0];
|
|
95
|
+
if (request?.type !== "step") throw new Error("missing request");
|
|
96
|
+
|
|
97
|
+
expect(stepAnswer(index, request)).toBe("GetResponse");
|
|
98
|
+
expect(flowAnswers(index, flow).has(request.id)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/flow/answers.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
// What comes back from a call, when a contract says.
|
|
2
2
|
//
|
|
3
|
-
// The catalog
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// in the proto that declares the method, and in the OpenAPI document that
|
|
8
|
-
// declares the operation. So it is looked up rather than recorded, and a step
|
|
9
|
-
// whose method nobody in the catalog provides simply has no answer to draw.
|
|
3
|
+
// The catalog's source facts are hops - a call was made, an event was
|
|
4
|
+
// published. Composition may synthesize a response when a proven synchronous
|
|
5
|
+
// continuation returns, but standalone calls still need their answer read from
|
|
6
|
+
// the contract and shown on the request itself.
|
|
10
7
|
//
|
|
11
8
|
// Only an rpc has one. A `call` lands inside a service - a repository, a
|
|
12
9
|
// queryset - which no interface in the catalog describes; an event is a
|
|
@@ -47,10 +44,16 @@ export function stepAnswer(index: CatalogIndex, step: Step): string | undefined
|
|
|
47
44
|
return methodOf(index, step)?.response || undefined;
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
/** Every
|
|
47
|
+
/** Every request without an explicit response step that has an answer. */
|
|
51
48
|
export function flowAnswers(index: CatalogIndex, flow: Flow): Map<string, string> {
|
|
52
49
|
const out = new Map<string, string>();
|
|
50
|
+
const explicit = new Set(
|
|
51
|
+
walkSteps(flow.steps)
|
|
52
|
+
.filter((step) => step.kind === "response" && step.replyTo)
|
|
53
|
+
.map((step) => step.replyTo as string),
|
|
54
|
+
);
|
|
53
55
|
for (const step of walkSteps(flow.steps)) {
|
|
56
|
+
if (explicit.has(step.id)) continue;
|
|
54
57
|
const answer = stepAnswer(index, step);
|
|
55
58
|
if (answer) out.set(step.id, answer);
|
|
56
59
|
}
|
package/src/flow/chapters.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import type { Flow, FlowNode, Status, Step } from "../catalog";
|
|
22
22
|
import { walkSteps } from "../catalog";
|
|
23
23
|
import { contextResolver } from "./cross-context";
|
|
24
|
+
import { stepLabel } from "./labels";
|
|
24
25
|
import type { OutlineRow } from "./outline";
|
|
25
26
|
|
|
26
27
|
export type ChapterKind = "alt" | "par" | "loop" | "steps";
|
|
@@ -55,7 +56,7 @@ export interface ChapterGroup {
|
|
|
55
56
|
|
|
56
57
|
/** What a step is called when it has to name something. */
|
|
57
58
|
export function stepTitle(step: Step): string {
|
|
58
|
-
return step
|
|
59
|
+
return stepLabel(step);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
const NO_STATUS: Record<Status, number> = {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Step } from "../catalog";
|
|
2
|
+
|
|
3
|
+
/** The message a reader sees everywhere a flow step is listed or drawn. */
|
|
4
|
+
export function stepLabel(step: Step): string {
|
|
5
|
+
const access = step.storeAccess;
|
|
6
|
+
if (access?.operation && access.keyspace) {
|
|
7
|
+
return `${access.operation.toUpperCase()} ${access.keyspace}`;
|
|
8
|
+
}
|
|
9
|
+
return step.label ?? step.ref ?? step.kind;
|
|
10
|
+
}
|
package/src/flow/mermaid.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { Flow } from "../catalog";
|
|
3
4
|
import { catalog, index } from "../data";
|
|
4
5
|
import { flowAnswers } from "./answers";
|
|
5
6
|
import { flowMermaid } from "./mermaid";
|
|
@@ -15,8 +16,91 @@ function generated(slug: string): string {
|
|
|
15
16
|
describe("flowMermaid", () => {
|
|
16
17
|
// The Go generator and this port draw the same diagram, or one of them is
|
|
17
18
|
// wrong; the generated pages are the record of what the Go one drew.
|
|
18
|
-
it.each(catalog.flows.map((f) => f.slug))(
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
it.each(catalog.flows.map((f) => f.slug))(
|
|
20
|
+
"draws %s as gen-markdown does",
|
|
21
|
+
(slug) => {
|
|
22
|
+
const flow = catalog.flows.find((f) => f.slug === slug)!;
|
|
23
|
+
expect(flowMermaid(flow, flowAnswers(index, flow))).toBe(generated(slug));
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
it("draws a synthesized response as a dashed reverse message", () => {
|
|
28
|
+
const flow: Flow = {
|
|
29
|
+
id: "flow.reply",
|
|
30
|
+
slug: "reply",
|
|
31
|
+
name: "Reply",
|
|
32
|
+
summary: "",
|
|
33
|
+
owner: "shop",
|
|
34
|
+
participants: [
|
|
35
|
+
{ id: "client", kind: "actor", context: null },
|
|
36
|
+
{ id: "shop.api", kind: "service", context: "shop" },
|
|
37
|
+
],
|
|
38
|
+
steps: [
|
|
39
|
+
{
|
|
40
|
+
type: "step",
|
|
41
|
+
id: "request",
|
|
42
|
+
from: "client",
|
|
43
|
+
to: "shop.api",
|
|
44
|
+
kind: "rpc",
|
|
45
|
+
label: "GET /book",
|
|
46
|
+
status: "declared",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
type: "step",
|
|
50
|
+
id: "response",
|
|
51
|
+
from: "shop.api",
|
|
52
|
+
to: "client",
|
|
53
|
+
kind: "response",
|
|
54
|
+
label: "HTTP response",
|
|
55
|
+
status: "declared",
|
|
56
|
+
replyTo: "request",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
expect(flowMermaid(flow)).toContain(
|
|
62
|
+
"p0->>p1: GET /book\n p1-->>p0: HTTP response",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const response = flow.steps[1];
|
|
66
|
+
if (response?.type !== "step") throw new Error("response fixture missing");
|
|
67
|
+
response.http = { status: 500, outcome: "error" };
|
|
68
|
+
response.label = "500 · Error";
|
|
69
|
+
expect(flowMermaid(flow)).toContain(
|
|
70
|
+
"rect rgba(183, 100, 107, 0.12)\n p1-->>p0: 500 · Error\n end",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("labels a resolved Redis call by its concrete operation and key", () => {
|
|
75
|
+
const flow: Flow = {
|
|
76
|
+
id: "flow.redis",
|
|
77
|
+
slug: "redis",
|
|
78
|
+
name: "Redis",
|
|
79
|
+
summary: "",
|
|
80
|
+
owner: "shop",
|
|
81
|
+
participants: [
|
|
82
|
+
{ id: "shop.api", kind: "service", context: "shop" },
|
|
83
|
+
{ id: "shop.redis", kind: "store", context: "shop" },
|
|
84
|
+
],
|
|
85
|
+
steps: [
|
|
86
|
+
{
|
|
87
|
+
type: "step",
|
|
88
|
+
id: "read",
|
|
89
|
+
from: "shop.api",
|
|
90
|
+
to: "shop.redis",
|
|
91
|
+
kind: "call",
|
|
92
|
+
label: "Get",
|
|
93
|
+
status: "verified",
|
|
94
|
+
storeAccess: {
|
|
95
|
+
store: "shop.redis",
|
|
96
|
+
method: "Store.Get",
|
|
97
|
+
operation: "read",
|
|
98
|
+
keyspace: "{id}",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
expect(flowMermaid(flow)).toContain("p0->>p1: READ {id}");
|
|
21
105
|
});
|
|
22
106
|
});
|
package/src/flow/mermaid.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// the catalog has a dotted service id.
|
|
8
8
|
|
|
9
9
|
import type { Flow, FlowNode, Step } from "../catalog";
|
|
10
|
+
import { stepLabel } from "./labels";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* `answers` is what each step's callee hands back, by step id - see
|
|
@@ -42,17 +43,28 @@ function emit(
|
|
|
42
43
|
for (const node of nodes) {
|
|
43
44
|
switch (node.type) {
|
|
44
45
|
case "step": {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
const arrow =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
// Events use the async arrow; synthesized responses use the standard
|
|
47
|
+
// dashed return. A reader can now distinguish all three directions.
|
|
48
|
+
const arrow =
|
|
49
|
+
node.kind === "response"
|
|
50
|
+
? "-->>"
|
|
51
|
+
: node.kind === "event"
|
|
52
|
+
? "-)"
|
|
53
|
+
: "->>";
|
|
54
|
+
const message = `${of(node.from)}${arrow}${of(node.to)}: ${text(labelWithAnswer(node, answers))}`;
|
|
55
|
+
if (node.http?.outcome === "error") {
|
|
56
|
+
out.push(`${indent}rect rgba(183, 100, 107, 0.12)`);
|
|
57
|
+
out.push(`${indent} ${message}`);
|
|
58
|
+
out.push(`${indent}end`);
|
|
59
|
+
} else {
|
|
60
|
+
out.push(`${indent}${message}`);
|
|
61
|
+
}
|
|
52
62
|
break;
|
|
53
63
|
}
|
|
54
64
|
case "parallel": {
|
|
55
|
-
out.push(
|
|
65
|
+
out.push(
|
|
66
|
+
`${indent}par ${text(node.title?.trim() ? node.title : "in parallel")}`,
|
|
67
|
+
);
|
|
56
68
|
node.branches.forEach((branch, i) => {
|
|
57
69
|
if (i > 0) out.push(`${indent}and`);
|
|
58
70
|
emit(branch, of, depth + 1, out, answers);
|
|
@@ -62,14 +74,18 @@ function emit(
|
|
|
62
74
|
}
|
|
63
75
|
case "alt": {
|
|
64
76
|
node.branches.forEach((branch, i) => {
|
|
65
|
-
out.push(
|
|
77
|
+
out.push(
|
|
78
|
+
`${indent}${i === 0 ? "alt " : "else "}${text(branch.title)}`,
|
|
79
|
+
);
|
|
66
80
|
emit(branch.steps, of, depth + 1, out, answers);
|
|
67
81
|
// A branch that ends the flow has to say so inside the diagram, or
|
|
68
82
|
// the steps drawn after the alt read as if they follow it too.
|
|
69
83
|
if (branch.terminal) {
|
|
70
84
|
const last = lastParticipant(branch.steps);
|
|
71
85
|
if (last)
|
|
72
|
-
out.push(
|
|
86
|
+
out.push(
|
|
87
|
+
`${" ".repeat(depth + 1)}Note over ${of(last)}: flow ends here`,
|
|
88
|
+
);
|
|
73
89
|
}
|
|
74
90
|
});
|
|
75
91
|
out.push(`${indent}end`);
|
|
@@ -85,10 +101,6 @@ function emit(
|
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
function stepLabel(step: Step): string {
|
|
89
|
-
return step.label || step.ref || step.kind;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
104
|
function lastParticipant(nodes: FlowNode[]): string {
|
|
93
105
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
94
106
|
const node = nodes[i]!;
|
package/src/index.css
CHANGED
|
@@ -54,6 +54,10 @@
|
|
|
54
54
|
/* Domain events are the loudest leaf in the tree. Amber, but a step darker
|
|
55
55
|
than amber-600 so 12px mono still clears contrast on white. */
|
|
56
56
|
--kind-event: #b45309;
|
|
57
|
+
/* Error paths should be unmistakable without turning the diagram into an
|
|
58
|
+
alarm panel: dusty rose on a very pale wash. */
|
|
59
|
+
--response-error: #b7646b;
|
|
60
|
+
--response-error-bg: #faedef;
|
|
57
61
|
|
|
58
62
|
/* GitHub paints `[!IMPORTANT]` purple. Purple already exists in the context
|
|
59
63
|
ramp, where it means "this context"; the alert gets a name of its own so
|
|
@@ -170,6 +174,8 @@
|
|
|
170
174
|
--status-unresolved: #ff6b63;
|
|
171
175
|
|
|
172
176
|
--kind-event: #e8a33d;
|
|
177
|
+
--response-error: #d98288;
|
|
178
|
+
--response-error-bg: #2a1c20;
|
|
173
179
|
|
|
174
180
|
--alert-important: #b98cf0;
|
|
175
181
|
|
|
@@ -567,22 +573,6 @@
|
|
|
567
573
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
568
574
|
}
|
|
569
575
|
|
|
570
|
-
/* Sanctioned gradient 2 - the accent surface. Reserved for a primary button;
|
|
571
|
-
white on --accent-lo clears 4.5:1 in both themes. */
|
|
572
|
-
.btn-accent {
|
|
573
|
-
@apply mono flex items-center gap-1.5 rounded-control px-3 py-1.5;
|
|
574
|
-
color: #fff;
|
|
575
|
-
background-image: linear-gradient(
|
|
576
|
-
to bottom,
|
|
577
|
-
var(--accent-hi),
|
|
578
|
-
var(--accent-lo)
|
|
579
|
-
);
|
|
580
|
-
transition: filter var(--dur-micro) var(--ease-out);
|
|
581
|
-
}
|
|
582
|
-
.btn-accent:hover {
|
|
583
|
-
filter: brightness(1.06);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
576
|
/* The landing's primary action should feel like a control from the map,
|
|
587
577
|
not a bright rectangle pasted over it. The depth is always present; the
|
|
588
578
|
brief chart-line sweep only appears when a reader points at the action. */
|
|
@@ -616,6 +606,9 @@
|
|
|
616
606
|
border-color var(--dur-micro) var(--ease-out),
|
|
617
607
|
box-shadow var(--dur-panel) var(--ease-out);
|
|
618
608
|
}
|
|
609
|
+
.product-primary {
|
|
610
|
+
cursor: pointer;
|
|
611
|
+
}
|
|
619
612
|
:root:not(.dark) :is(.landing-primary, .product-primary) {
|
|
620
613
|
color: var(--product-action-ink);
|
|
621
614
|
border-color: color-mix(in srgb, var(--product-action) 52%, var(--border));
|
|
@@ -688,7 +681,6 @@
|
|
|
688
681
|
outline: 2px solid color-mix(in srgb, var(--accent) 55%, white);
|
|
689
682
|
outline-offset: 3px;
|
|
690
683
|
}
|
|
691
|
-
.btn-accent:disabled,
|
|
692
684
|
.product-primary:disabled,
|
|
693
685
|
.tbtn:disabled {
|
|
694
686
|
cursor: not-allowed;
|
|
@@ -1015,6 +1007,129 @@
|
|
|
1015
1007
|
font-size: var(--text-meta);
|
|
1016
1008
|
}
|
|
1017
1009
|
|
|
1010
|
+
/* Cats mark rare, positive empty states and milestones rather than routine gaps. */
|
|
1011
|
+
.cat-illustration {
|
|
1012
|
+
display: block;
|
|
1013
|
+
object-fit: contain;
|
|
1014
|
+
pointer-events: none;
|
|
1015
|
+
user-select: none;
|
|
1016
|
+
filter: drop-shadow(0 12px 18px rgb(10 18 26 / 10%));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.cat-empty-state {
|
|
1020
|
+
@apply glow grid items-center gap-5 rounded-card border border-dashed border-line-strong px-5 py-4;
|
|
1021
|
+
grid-template-columns: 180px minmax(0, 1fr);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.cat-empty-illustration {
|
|
1025
|
+
width: 180px;
|
|
1026
|
+
height: 126px;
|
|
1027
|
+
justify-self: center;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
.cat-onboarding-illustration {
|
|
1031
|
+
width: 104px;
|
|
1032
|
+
height: 82px;
|
|
1033
|
+
margin-block: -10px;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.cat-search-empty {
|
|
1037
|
+
@apply glow flex items-center justify-center gap-4 px-4 py-4;
|
|
1038
|
+
min-height: 104px;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
.cat-search-illustration {
|
|
1042
|
+
width: 92px;
|
|
1043
|
+
height: 68px;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.cat-trial-illustration {
|
|
1047
|
+
width: 120px;
|
|
1048
|
+
height: 88px;
|
|
1049
|
+
margin-block: -8px;
|
|
1050
|
+
justify-self: center;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
@media (max-width: 520px) {
|
|
1054
|
+
.cat-empty-state {
|
|
1055
|
+
grid-template-columns: 104px minmax(0, 1fr);
|
|
1056
|
+
gap: 16px;
|
|
1057
|
+
padding: 16px;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
.cat-empty-illustration {
|
|
1061
|
+
width: 104px;
|
|
1062
|
+
height: 92px;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.cat-onboarding-illustration {
|
|
1066
|
+
width: 76px;
|
|
1067
|
+
height: 66px;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.cat-trial-illustration {
|
|
1071
|
+
width: 90px;
|
|
1072
|
+
height: 72px;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/* The 404 is a small editorial moment inside the catalog rather than a
|
|
1077
|
+
centred error dialog. Copy keeps a readable left edge; the transparent
|
|
1078
|
+
explorer sits quietly beside it. */
|
|
1079
|
+
.not-found-page {
|
|
1080
|
+
@apply glow flex h-full min-h-0 items-center justify-center overflow-y-auto p-gutter;
|
|
1081
|
+
}
|
|
1082
|
+
.not-found-state {
|
|
1083
|
+
display: grid;
|
|
1084
|
+
grid-template-columns: minmax(260px, 0.9fr) minmax(360px, 1fr);
|
|
1085
|
+
align-items: center;
|
|
1086
|
+
gap: clamp(32px, 6vw, 72px);
|
|
1087
|
+
width: min(100%, 980px);
|
|
1088
|
+
margin: auto;
|
|
1089
|
+
}
|
|
1090
|
+
.not-found-copy {
|
|
1091
|
+
max-width: 480px;
|
|
1092
|
+
}
|
|
1093
|
+
.not-found-art {
|
|
1094
|
+
position: relative;
|
|
1095
|
+
aspect-ratio: 4 / 3;
|
|
1096
|
+
}
|
|
1097
|
+
.not-found-cat {
|
|
1098
|
+
width: 100%;
|
|
1099
|
+
height: 100%;
|
|
1100
|
+
object-fit: contain;
|
|
1101
|
+
user-select: none;
|
|
1102
|
+
filter: drop-shadow(0 18px 22px rgb(10 18 26 / 0.12));
|
|
1103
|
+
}
|
|
1104
|
+
.not-found-request {
|
|
1105
|
+
@apply grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-control border border-line bg-surface px-3 py-2;
|
|
1106
|
+
}
|
|
1107
|
+
.not-found-key {
|
|
1108
|
+
margin-left: 2px;
|
|
1109
|
+
padding: 0 4px;
|
|
1110
|
+
border: 1px solid color-mix(in srgb, var(--fg) 22%, transparent);
|
|
1111
|
+
border-radius: 4px;
|
|
1112
|
+
color: inherit;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
@media (max-width: 760px) {
|
|
1116
|
+
.not-found-page {
|
|
1117
|
+
align-items: flex-start;
|
|
1118
|
+
}
|
|
1119
|
+
.not-found-state {
|
|
1120
|
+
grid-template-columns: 1fr;
|
|
1121
|
+
gap: 8px;
|
|
1122
|
+
width: min(100%, 520px);
|
|
1123
|
+
}
|
|
1124
|
+
.not-found-art {
|
|
1125
|
+
width: min(88%, 380px);
|
|
1126
|
+
margin-inline: auto;
|
|
1127
|
+
}
|
|
1128
|
+
.not-found-copy {
|
|
1129
|
+
max-width: none;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1018
1133
|
/* ---------------------------------------------------------------------
|
|
1019
1134
|
Identifiers. Every id, path and type name in the app is an <Ident>, and
|
|
1020
1135
|
every <Ident> is this button: it looks like text until the pointer is on
|