@shortlink-org/portolan 0.2.1 → 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 +363 -28
- package/scripts/local-api.test.mjs +195 -1
- 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/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
|
|
|
@@ -616,6 +622,9 @@
|
|
|
616
622
|
border-color var(--dur-micro) var(--ease-out),
|
|
617
623
|
box-shadow var(--dur-panel) var(--ease-out);
|
|
618
624
|
}
|
|
625
|
+
.product-primary {
|
|
626
|
+
cursor: pointer;
|
|
627
|
+
}
|
|
619
628
|
:root:not(.dark) :is(.landing-primary, .product-primary) {
|
|
620
629
|
color: var(--product-action-ink);
|
|
621
630
|
border-color: color-mix(in srgb, var(--product-action) 52%, var(--border));
|
|
@@ -1015,6 +1024,129 @@
|
|
|
1015
1024
|
font-size: var(--text-meta);
|
|
1016
1025
|
}
|
|
1017
1026
|
|
|
1027
|
+
/* Cats mark rare, positive empty states and milestones rather than routine gaps. */
|
|
1028
|
+
.cat-illustration {
|
|
1029
|
+
display: block;
|
|
1030
|
+
object-fit: contain;
|
|
1031
|
+
pointer-events: none;
|
|
1032
|
+
user-select: none;
|
|
1033
|
+
filter: drop-shadow(0 12px 18px rgb(10 18 26 / 10%));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.cat-empty-state {
|
|
1037
|
+
@apply glow grid items-center gap-5 rounded-card border border-dashed border-line-strong px-5 py-4;
|
|
1038
|
+
grid-template-columns: 180px minmax(0, 1fr);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
.cat-empty-illustration {
|
|
1042
|
+
width: 180px;
|
|
1043
|
+
height: 126px;
|
|
1044
|
+
justify-self: center;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
.cat-onboarding-illustration {
|
|
1048
|
+
width: 104px;
|
|
1049
|
+
height: 82px;
|
|
1050
|
+
margin-block: -10px;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.cat-search-empty {
|
|
1054
|
+
@apply glow flex items-center justify-center gap-4 px-4 py-4;
|
|
1055
|
+
min-height: 104px;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.cat-search-illustration {
|
|
1059
|
+
width: 92px;
|
|
1060
|
+
height: 68px;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.cat-trial-illustration {
|
|
1064
|
+
width: 120px;
|
|
1065
|
+
height: 88px;
|
|
1066
|
+
margin-block: -8px;
|
|
1067
|
+
justify-self: center;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
@media (max-width: 520px) {
|
|
1071
|
+
.cat-empty-state {
|
|
1072
|
+
grid-template-columns: 104px minmax(0, 1fr);
|
|
1073
|
+
gap: 16px;
|
|
1074
|
+
padding: 16px;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.cat-empty-illustration {
|
|
1078
|
+
width: 104px;
|
|
1079
|
+
height: 92px;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
.cat-onboarding-illustration {
|
|
1083
|
+
width: 76px;
|
|
1084
|
+
height: 66px;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
.cat-trial-illustration {
|
|
1088
|
+
width: 90px;
|
|
1089
|
+
height: 72px;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/* The 404 is a small editorial moment inside the catalog rather than a
|
|
1094
|
+
centred error dialog. Copy keeps a readable left edge; the transparent
|
|
1095
|
+
explorer sits quietly beside it. */
|
|
1096
|
+
.not-found-page {
|
|
1097
|
+
@apply glow flex h-full min-h-0 items-center justify-center overflow-y-auto p-gutter;
|
|
1098
|
+
}
|
|
1099
|
+
.not-found-state {
|
|
1100
|
+
display: grid;
|
|
1101
|
+
grid-template-columns: minmax(260px, 0.9fr) minmax(360px, 1fr);
|
|
1102
|
+
align-items: center;
|
|
1103
|
+
gap: clamp(32px, 6vw, 72px);
|
|
1104
|
+
width: min(100%, 980px);
|
|
1105
|
+
margin: auto;
|
|
1106
|
+
}
|
|
1107
|
+
.not-found-copy {
|
|
1108
|
+
max-width: 480px;
|
|
1109
|
+
}
|
|
1110
|
+
.not-found-art {
|
|
1111
|
+
position: relative;
|
|
1112
|
+
aspect-ratio: 4 / 3;
|
|
1113
|
+
}
|
|
1114
|
+
.not-found-cat {
|
|
1115
|
+
width: 100%;
|
|
1116
|
+
height: 100%;
|
|
1117
|
+
object-fit: contain;
|
|
1118
|
+
user-select: none;
|
|
1119
|
+
filter: drop-shadow(0 18px 22px rgb(10 18 26 / 0.12));
|
|
1120
|
+
}
|
|
1121
|
+
.not-found-request {
|
|
1122
|
+
@apply grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-control border border-line bg-surface px-3 py-2;
|
|
1123
|
+
}
|
|
1124
|
+
.not-found-key {
|
|
1125
|
+
margin-left: 2px;
|
|
1126
|
+
padding: 0 4px;
|
|
1127
|
+
border: 1px solid color-mix(in srgb, var(--fg) 22%, transparent);
|
|
1128
|
+
border-radius: 4px;
|
|
1129
|
+
color: inherit;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
@media (max-width: 760px) {
|
|
1133
|
+
.not-found-page {
|
|
1134
|
+
align-items: flex-start;
|
|
1135
|
+
}
|
|
1136
|
+
.not-found-state {
|
|
1137
|
+
grid-template-columns: 1fr;
|
|
1138
|
+
gap: 8px;
|
|
1139
|
+
width: min(100%, 520px);
|
|
1140
|
+
}
|
|
1141
|
+
.not-found-art {
|
|
1142
|
+
width: min(88%, 380px);
|
|
1143
|
+
margin-inline: auto;
|
|
1144
|
+
}
|
|
1145
|
+
.not-found-copy {
|
|
1146
|
+
max-width: none;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1018
1150
|
/* ---------------------------------------------------------------------
|
|
1019
1151
|
Identifiers. Every id, path and type name in the app is an <Ident>, and
|
|
1020
1152
|
every <Ident> is this button: it looks like text until the pointer is on
|
|
@@ -30,8 +30,12 @@ import { DraggableReveal } from "./DraggableReveal";
|
|
|
30
30
|
import { heroColumn, heroLine, Reveal } from "./motion";
|
|
31
31
|
import { ProductFrame } from "./ProductFrame";
|
|
32
32
|
import { ProductTour } from "./ProductTour";
|
|
33
|
+
import {
|
|
34
|
+
PRODUCT_LICENSE,
|
|
35
|
+
PRODUCT_README,
|
|
36
|
+
PRODUCT_REPOSITORY,
|
|
37
|
+
} from "../lib/product";
|
|
33
38
|
|
|
34
|
-
const REPOSITORY = "https://github.com/shortlink-org/portolan";
|
|
35
39
|
const EXAMPLE_DOCS = "https://shortlink-org.github.io/portolan/docs/example";
|
|
36
40
|
const exampleTo = catalogTo(paths.overview());
|
|
37
41
|
|
|
@@ -86,7 +90,7 @@ function Header() {
|
|
|
86
90
|
{theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
|
|
87
91
|
</button>
|
|
88
92
|
<a
|
|
89
|
-
href={
|
|
93
|
+
href={PRODUCT_REPOSITORY}
|
|
90
94
|
className="tbtn hidden sm:flex"
|
|
91
95
|
target="_blank"
|
|
92
96
|
rel="noreferrer"
|
|
@@ -362,7 +366,7 @@ export function LandingPage() {
|
|
|
362
366
|
<div className="shrink-0">
|
|
363
367
|
<div className="mono text-faint">BUILT-IN INPUTS</div>
|
|
364
368
|
<a
|
|
365
|
-
href={`${
|
|
369
|
+
href={`${PRODUCT_REPOSITORY}#plugins`}
|
|
366
370
|
target="_blank"
|
|
367
371
|
rel="noreferrer"
|
|
368
372
|
className="mt-1 inline-flex items-center gap-1 text-sm text-accent hover:underline"
|
|
@@ -648,7 +652,7 @@ export function LandingPage() {
|
|
|
648
652
|
Open the example <ArrowRight size={15} />
|
|
649
653
|
</Link>
|
|
650
654
|
<a
|
|
651
|
-
href={`${
|
|
655
|
+
href={`${PRODUCT_REPOSITORY}#use-it-in-your-project`}
|
|
652
656
|
className="tbtn px-4 py-2.5 text-ink"
|
|
653
657
|
target="_blank"
|
|
654
658
|
rel="noreferrer"
|
|
@@ -720,7 +724,7 @@ export function LandingPage() {
|
|
|
720
724
|
Explore example catalog <ArrowRight size={15} />
|
|
721
725
|
</Link>
|
|
722
726
|
<a
|
|
723
|
-
href={
|
|
727
|
+
href={PRODUCT_REPOSITORY}
|
|
724
728
|
target="_blank"
|
|
725
729
|
rel="noreferrer"
|
|
726
730
|
className="tbtn px-4 py-2.5 text-ink"
|
|
@@ -737,16 +741,16 @@ export function LandingPage() {
|
|
|
737
741
|
<Wordmark />
|
|
738
742
|
<span className="sm:ml-3">Architecture from code and evidence.</span>
|
|
739
743
|
<div className="flex gap-5 sm:ml-auto">
|
|
740
|
-
<a className="hover:text-ink" href={
|
|
744
|
+
<a className="hover:text-ink" href={PRODUCT_README}>
|
|
741
745
|
Docs
|
|
742
746
|
</a>
|
|
743
747
|
<a
|
|
744
748
|
className="hover:text-ink"
|
|
745
|
-
href={
|
|
749
|
+
href={PRODUCT_LICENSE}
|
|
746
750
|
>
|
|
747
751
|
MIT License
|
|
748
752
|
</a>
|
|
749
|
-
<a className="hover:text-ink" href={
|
|
753
|
+
<a className="hover:text-ink" href={PRODUCT_REPOSITORY}>
|
|
750
754
|
GitHub
|
|
751
755
|
</a>
|
|
752
756
|
</div>
|