@shortlink-org/portolan 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/README.md +5 -0
  5. package/plugins/extract-python-kafka/README.md +6 -0
  6. package/plugins/extract-python-kafka/extract.py +2 -2
  7. package/plugins/extract-python-kafka/extract_test.py +17 -1
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/plugins/pyplugin/catalog.py +12 -1
  10. package/plugins/pyplugin/kafka.py +74 -3
  11. package/scripts/builtin-plugins.mjs +3 -2
  12. package/scripts/catalog-sources.mjs +2 -1
  13. package/scripts/catalog-sources.test.mjs +11 -1
  14. package/scripts/delivery-presets.mjs +207 -24
  15. package/scripts/diff.mjs +5 -1
  16. package/scripts/gen-likec4.mjs +78 -20
  17. package/scripts/gen-likec4.test.mjs +25 -2
  18. package/scripts/local-api.mjs +26 -377
  19. package/scripts/local-api.test.mjs +50 -0
  20. package/scripts/local-discovery.mjs +378 -0
  21. package/scripts/manifest.mjs +42 -1
  22. package/scripts/manifest.test.mjs +24 -1
  23. package/scripts/run-builtin.mjs +4 -2
  24. package/scripts/schema.mjs +4 -0
  25. package/scripts/site-docs.mjs +2 -2
  26. package/src/app/Breadcrumbs.test.ts +4 -0
  27. package/src/app/Breadcrumbs.tsx +1 -0
  28. package/src/app/CatalogApp.tsx +4 -4
  29. package/src/app/Sidebar.tsx +13 -730
  30. package/src/app/SidebarFlowSections.tsx +251 -0
  31. package/src/app/SidebarFooter.tsx +160 -0
  32. package/src/app/SidebarTree.tsx +322 -0
  33. package/src/catalog-index.ts +485 -0
  34. package/src/catalog-model.ts +1360 -0
  35. package/src/catalog-stores.test.ts +17 -0
  36. package/src/catalog-validation.ts +1611 -0
  37. package/src/catalog.test.ts +29 -2
  38. package/src/catalog.ts +6 -3306
  39. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  40. package/src/components/ChannelRows.test.tsx +54 -0
  41. package/src/components/ChannelRows.tsx +57 -10
  42. package/src/components/LifecycleDiagram.tsx +28 -12
  43. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  44. package/src/components/ProblemRow.tsx +7 -0
  45. package/src/components/SourcePreview.tsx +1 -1
  46. package/src/components/WhatLinksHere.tsx +6 -4
  47. package/src/enrich.test.ts +4 -5
  48. package/src/flow/StepDetail.tsx +98 -54
  49. package/src/flow/answers.test.ts +18 -1
  50. package/src/flow/answers.ts +37 -8
  51. package/src/index.css +0 -17
  52. package/src/landing/LandingPage.tsx +4 -4
  53. package/src/lib/all-problems.ts +1 -1
  54. package/src/lib/backlinks.test.ts +16 -1
  55. package/src/lib/backlinks.ts +20 -0
  56. package/src/lib/catalog-diff.test.ts +18 -0
  57. package/src/lib/catalog-diff.ts +19 -1
  58. package/src/lib/derive.ts +2 -0
  59. package/src/lib/kafka-ui.test.ts +87 -0
  60. package/src/lib/kafka-ui.ts +105 -0
  61. package/src/lib/local-api.ts +19 -7
  62. package/src/lib/motion.test.ts +4 -2
  63. package/src/lib/motion.tsx +5 -4
  64. package/src/lib/proto-problems.test.ts +170 -3
  65. package/src/lib/proto-problems.ts +176 -4
  66. package/src/lib/wire-problems.test.ts +21 -0
  67. package/src/lib/wire-problems.ts +62 -1
  68. package/src/likec4/FlowView.tsx +2 -6
  69. package/src/likec4/flow-edges.test.ts +64 -1
  70. package/src/likec4/flow-edges.ts +43 -7
  71. package/src/likec4/view-index.ts +8 -2
  72. package/src/merge.test.ts +69 -0
  73. package/src/merge.ts +52 -2
  74. package/src/pages/CatalogFailure.tsx +2 -2
  75. package/src/pages/Settings.tsx +12 -129
  76. package/src/pages/settings/AboutSettings.tsx +129 -0
  77. package/src/pages/settings/DeliverySettings.tsx +71 -15
  78. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  79. package/src/routes.test.ts +2 -0
  80. package/src/routes.ts +2 -1
  81. package/src/selection/DetailPanel.tsx +46 -1
  82. package/src/selection/pages.test.ts +14 -1
  83. package/src/selection/pages.ts +9 -3
  84. package/vite.config.ts +3 -1
@@ -0,0 +1,28 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { MemoryRouter } from "react-router";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { Channel } from "../catalog";
5
+ import { ChannelRows } from "./ChannelRows";
6
+
7
+ describe("ChannelRows MessagePack presentation", () => {
8
+ it("shows a machine-readable encoding with its declared content type", () => {
9
+ const channel: Channel = {
10
+ address: "inventory.snapshots",
11
+ kind: "message",
12
+ messages: [{
13
+ name: "inventory.Snapshot",
14
+ direction: "send",
15
+ encoding: "msgpack",
16
+ contentType: "application/msgpack",
17
+ }],
18
+ };
19
+ const markup = renderToStaticMarkup(
20
+ <MemoryRouter>
21
+ <ChannelRows channels={[channel]} service="shop.inventory" />
22
+ </MemoryRouter>,
23
+ );
24
+
25
+ expect(markup).toContain('title="application/msgpack"');
26
+ expect(markup).toContain(">msgpack</span>");
27
+ });
28
+ });
@@ -0,0 +1,54 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { MemoryRouter } from "react-router";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { Channel } from "../catalog";
5
+ import { ChannelRowsContent } from "./ChannelRows";
6
+
7
+ const kafka: Channel = {
8
+ address: "orders/created",
9
+ kind: "message",
10
+ title: "Kafka · orders/created",
11
+ messages: [],
12
+ };
13
+
14
+ const nats: Channel = {
15
+ address: "orders.created",
16
+ kind: "message",
17
+ title: "JetStream subject",
18
+ messages: [],
19
+ };
20
+
21
+ describe("ChannelRows Kafka UI integration", () => {
22
+ it("links only Kafka cards to their exact topic when configured", () => {
23
+ const markup = renderToStaticMarkup(
24
+ <MemoryRouter>
25
+ <ChannelRowsContent
26
+ channels={[kafka, nats]}
27
+ service="shop.orders"
28
+ kafkaUi="https://ops.example/ui/clusters/prod"
29
+ />
30
+ </MemoryRouter>,
31
+ );
32
+
33
+ expect(markup).toContain("view in Kafka UI");
34
+ expect(markup).toContain(
35
+ 'href="https://ops.example/ui/clusters/prod/all-topics/orders%2Fcreated"',
36
+ );
37
+ expect(markup.match(/view in Kafka UI/g)).toHaveLength(1);
38
+ });
39
+
40
+ it("does not offer an operational link before Kafka UI is configured", () => {
41
+ const markup = renderToStaticMarkup(
42
+ <MemoryRouter>
43
+ <ChannelRowsContent
44
+ channels={[kafka]}
45
+ service="shop.orders"
46
+ kafkaUi=""
47
+ />
48
+ </MemoryRouter>,
49
+ );
50
+
51
+ expect(markup).not.toContain("view in Kafka UI");
52
+ });
53
+
54
+ });
@@ -13,13 +13,22 @@
13
13
  // Problems page says which; a row that quietly dropped it would be the site
14
14
  // hiding the interesting case.
15
15
 
16
+ import { ExternalLink } from "lucide-react";
16
17
  import { Link } from "react-router";
17
18
  import type { Channel, ChannelMessage } from "../catalog";
18
- import { index } from "../data";
19
+ import { catalog, index } from "../data";
20
+ import {
21
+ isKafkaChannel,
22
+ kafkaHandoffChannels,
23
+ kafkaUiTopicUrl,
24
+ useKafkaUi,
25
+ } from "../lib/kafka-ui";
19
26
  import { eventPath } from "../routes";
20
27
  import { Ident } from "./Ident";
21
28
  import { RowActions } from "./RowActions";
22
29
 
30
+ const KAFKA_HANDOFF_CHANNELS = kafkaHandoffChannels(catalog);
31
+
23
32
  /** The event that goes out under a wire name, when the catalog knows one. */
24
33
  function publisherOf(name: string) {
25
34
  const event = index.eventByWireName.get(name);
@@ -75,6 +84,11 @@ function MessageRow({
75
84
  {elsewhere}
76
85
  </span>
77
86
  ) : null}
87
+ {message.encoding || message.contentType ? (
88
+ <span className="chip mono" title={message.contentType || "payload encoding"}>
89
+ {message.encoding || message.contentType}
90
+ </span>
91
+ ) : null}
78
92
  </div>
79
93
  {message.title || message.doc ? (
80
94
  <p className="mt-0.5 text-muted">{message.doc || message.title}</p>
@@ -91,20 +105,53 @@ export function ChannelRows({
91
105
  }: {
92
106
  channels: Channel[];
93
107
  service: string;
108
+ }) {
109
+ const kafkaUi = useKafkaUi((state) => state.url);
110
+ return (
111
+ <ChannelRowsContent
112
+ channels={channels}
113
+ service={service}
114
+ kafkaUi={kafkaUi}
115
+ />
116
+ );
117
+ }
118
+
119
+ /** The presentational half is exported so the generated links can be rendered in isolation. */
120
+ export function ChannelRowsContent({
121
+ channels,
122
+ service,
123
+ kafkaUi,
124
+ }: {
125
+ channels: Channel[];
126
+ service: string;
127
+ kafkaUi: string;
94
128
  }) {
95
129
  return (
96
130
  <div className="flex flex-col gap-section" data-nav-list>
97
131
  {channels.map((channel) => (
98
132
  <div key={channel.address} className="rounded-card border border-line">
99
- <div className="flex flex-wrap items-baseline gap-x-2 border-b border-line px-3 py-2">
100
- <Ident value={channel.address} />
101
- {channel.kind === "job" ? (
102
- <span className="chip">work queue</span>
103
- ) : channel.kind === "message" ? (
104
- <span className="chip">message stream</span>
105
- ) : null}
106
- {channel.title ? (
107
- <span className="text-muted">{channel.title}</span>
133
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line px-3 py-2">
134
+ <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
135
+ <Ident value={channel.address} />
136
+ {channel.kind === "job" ? (
137
+ <span className="chip">work queue</span>
138
+ ) : channel.kind === "message" ? (
139
+ <span className="chip">message stream</span>
140
+ ) : null}
141
+ {channel.title ? (
142
+ <span className="text-muted">{channel.title}</span>
143
+ ) : null}
144
+ </div>
145
+ {kafkaUi && isKafkaChannel(channel, KAFKA_HANDOFF_CHANNELS) ? (
146
+ <a
147
+ href={kafkaUiTopicUrl(kafkaUi, channel.address) ?? kafkaUi}
148
+ target="_blank"
149
+ rel="noreferrer"
150
+ className="mono inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline"
151
+ title={`Open ${channel.address} in Kafka UI`}
152
+ >
153
+ view in Kafka UI <ExternalLink size={12} aria-hidden />
154
+ </a>
108
155
  ) : null}
109
156
  </div>
110
157
  {channel.doc ? (
@@ -3,6 +3,12 @@ import type { Aggregate } from "../catalog";
3
3
  import { layoutLifecycle, METRICS } from "../lib/lifecycle";
4
4
  import { KindIcon } from "./kind";
5
5
 
6
+ function transitionTone(on: string) {
7
+ if (on === "Fail") return { color: "var(--status-unresolved)", marker: "lc-arrow-fail" };
8
+ if (on === "Succeed") return { color: "var(--status-verified)", marker: "lc-arrow-succeed" };
9
+ return { color: "var(--fg-muted)", marker: "lc-arrow" };
10
+ }
11
+
6
12
  /**
7
13
  * The aggregate's state machine, as the code wrote it down. Boxes and arrows
8
14
  * are SVG; the labels are HTML laid over it, because a label holds a link to
@@ -29,18 +35,27 @@ export function LifecycleDiagram({
29
35
  <marker id="lc-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
30
36
  <path d="M 0 0 L 8 4 L 0 8 z" fill="var(--fg-muted)" />
31
37
  </marker>
38
+ <marker id="lc-arrow-fail" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
39
+ <path d="M 0 0 L 8 4 L 0 8 z" fill="var(--status-unresolved)" />
40
+ </marker>
41
+ <marker id="lc-arrow-succeed" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
42
+ <path d="M 0 0 L 8 4 L 0 8 z" fill="var(--status-verified)" />
43
+ </marker>
32
44
  </defs>
33
- {edges.map((e) => (
34
- <path
35
- key={`${e.from}-${e.to}-${e.on}`}
36
- d={e.path}
37
- fill="none"
38
- stroke="var(--fg-muted)"
39
- strokeWidth={1.25}
40
- strokeDasharray={e.back ? "4 3" : undefined}
41
- markerEnd="url(#lc-arrow)"
42
- />
43
- ))}
45
+ {edges.map((e) => {
46
+ const tone = transitionTone(e.on);
47
+ return (
48
+ <path
49
+ key={`${e.from}-${e.to}-${e.on}`}
50
+ d={e.path}
51
+ fill="none"
52
+ stroke={tone.color}
53
+ strokeWidth={1.25}
54
+ strokeDasharray={e.back ? "4 3" : undefined}
55
+ markerEnd={`url(#${tone.marker})`}
56
+ />
57
+ );
58
+ })}
44
59
  {boxes.map((b) => (
45
60
  <g key={b.state}>
46
61
  <rect
@@ -80,11 +95,12 @@ export function LifecycleDiagram({
80
95
  ))}
81
96
  {edges.map((e) => {
82
97
  const to = e.emits ? eventPath(e.emits) : null;
98
+ const tone = transitionTone(e.on);
83
99
  return (
84
100
  <div
85
101
  key={`${e.from}-${e.to}-${e.on}-label`}
86
102
  className="mono absolute flex -translate-x-1/2 -translate-y-1/2 items-center gap-1 whitespace-nowrap rounded-control bg-bg px-1 text-muted"
87
- style={{ left: e.labelX, top: e.labelY }}
103
+ style={{ left: e.labelX, top: e.labelY, color: tone.color }}
88
104
  title={e.source ? `made at ${e.source}` : undefined}
89
105
  >
90
106
  {e.on}
@@ -1,4 +1,3 @@
1
- import { createElement } from "react";
2
1
  import { renderToStaticMarkup } from "react-dom/server";
3
2
  import { describe, expect, it } from "vitest";
4
3
 
@@ -6,15 +5,15 @@ import { CapabilityEmpty } from "./PageHeader";
6
5
 
7
6
  describe("CapabilityEmpty", () => {
8
7
  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
- ));
8
+ const markup = renderToStaticMarkup(
9
+ <CapabilityEmpty
10
+ title="No dependency graph yet"
11
+ actions={<button type="button">Review extraction</button>}
12
+ signal="Signals: domain events and consumers."
13
+ >
14
+ The catalog has components, but no events to connect.
15
+ </CapabilityEmpty>,
16
+ );
18
17
 
19
18
  expect(markup).toContain("next useful step");
20
19
  expect(markup).toContain("No dependency graph yet");
@@ -27,9 +27,11 @@ const KIND_OF: Record<Problem["kind"], "service" | "event" | "table"> = {
27
27
  "column-type": "table",
28
28
  "outbox-payload": "table",
29
29
  "proto-missing": "service",
30
+ "proto-drift": "service",
30
31
  "shared-channel": "event",
31
32
  "channel-undeclared": "event",
32
33
  "channel-unpublished": "service",
34
+ "message-encoding": "service",
33
35
  "subscription-unresolved": "service",
34
36
  };
35
37
 
@@ -44,10 +46,12 @@ const KIND_NOTE: Record<Problem["kind"], string> = {
44
46
  "outbox-payload": "an outbox with no payload column",
45
47
  "proto-missing":
46
48
  "the provider is in the catalog but answers on no such method",
49
+ "proto-drift": "the vendored proto and provider schema disagree",
47
50
  "shared-channel": "a second service publishes on this channel",
48
51
  "channel-undeclared":
49
52
  "this event goes out on a channel the service does not declare",
50
53
  "channel-unpublished": "a declared channel no event of this service names",
54
+ "message-encoding": "publisher and subscriber use different payload encodings",
51
55
  "subscription-unresolved":
52
56
  "nothing in the catalog publishes what this service listens for",
53
57
  };
@@ -60,6 +64,7 @@ function nearPath(problem: Problem): string | null {
60
64
  // peer is nobody; this is a call whose peer is known and whose method is
61
65
  // not - and in both the thing to go and look at is the caller.
62
66
  case "proto-missing":
67
+ case "proto-drift":
63
68
  return servicePath(problem.service);
64
69
  case "consumer":
65
70
  case "channel-undeclared":
@@ -69,6 +74,7 @@ function nearPath(problem: Problem): string | null {
69
74
  case "shared-channel":
70
75
  return eventPath(problem.id) ?? servicePath(problem.service);
71
76
  case "channel-unpublished":
77
+ case "message-encoding":
72
78
  case "subscription-unresolved":
73
79
  return servicePath(problem.service);
74
80
  case "shared-store":
@@ -107,6 +113,7 @@ function peerPath(problem: Problem): string | null {
107
113
  );
108
114
  case "shared-store":
109
115
  case "shared-channel":
116
+ case "message-encoding":
110
117
  return servicePath(problem.peer);
111
118
  case "outbox-payload":
112
119
  return storePath(problem.peer);
@@ -106,7 +106,7 @@ function AccessForm({
106
106
  <button
107
107
  type="submit"
108
108
  disabled={!draft.trim()}
109
- className="btn-accent shrink-0 disabled:opacity-40"
109
+ className="product-primary shrink-0 disabled:opacity-40"
110
110
  >
111
111
  {connected ? "Replace token" : "Connect"}
112
112
  </button>
@@ -14,7 +14,11 @@ import { useMemo } from "react";
14
14
  import type { ReactNode } from "react";
15
15
  import { Link } from "react-router";
16
16
  import { catalog, index } from "../data";
17
- import { backlinkCount, backlinksFor } from "../lib/backlinks";
17
+ import {
18
+ backlinkCount,
19
+ backlinksFor,
20
+ distinctBacklinks,
21
+ } from "../lib/backlinks";
18
22
  import type { Backlink, BacklinkGroup, BacklinkTarget } from "../lib/backlinks";
19
23
  import { plural } from "../lib/format";
20
24
  import { KIND_LABEL, KIND_PLURAL } from "../lib/kinds";
@@ -163,9 +167,7 @@ export function WhatLinksHere({
163
167
  className={`mono flex flex-wrap items-center gap-1.5 text-muted ${className || "mt-2"}`}
164
168
  >
165
169
  linked from
166
- {groups
167
- .flatMap((g) => g.links)
168
- .map((link) => {
170
+ {distinctBacklinks(groups).map((link) => {
169
171
  const to = backlinkPath(link);
170
172
  const body = (
171
173
  <>
@@ -864,14 +864,13 @@ describe("enrichCatalog: calls from rpc steps", () => {
864
864
  expect(() => validateCatalog(catalog)).not.toThrow();
865
865
  });
866
866
 
867
- it("is what lets a step naming a provided method validate: the call is now known", () => {
868
- // The validator resolves a step's ref against declared calls, not against
869
- // what peers provide. Before this pass the step names a method no service
870
- // is on record as calling; after it, shop.oms is.
867
+ it("keeps a step naming a provided method valid while recording its caller", () => {
868
+ // The provided method is enough to resolve the flow step. Enrichment adds
869
+ // the separate fact that shop.oms is one of its callers.
871
870
  const c = estate([
872
871
  flow("a", [step("shop.oms", "shop.pricing", "rpc", { ref: METHOD })]),
873
872
  ]);
874
- expect(() => validateCatalog(c)).toThrow(/resolves to neither/);
873
+ expect(() => validateCatalog(c)).not.toThrow();
875
874
  expect(() => validateCatalog(enrichCatalog(c).catalog)).not.toThrow();
876
875
  });
877
876
 
@@ -1,18 +1,17 @@
1
1
  import { useMemo } from "react";
2
2
  import { Link } from "react-router";
3
- import { AlertTriangle } from "lucide-react";
3
+ import { AlertTriangle, FileCode2 } from "lucide-react";
4
4
  import { allRepos, stepFrames } from "../catalog";
5
5
  import type { Flow, Step, StepFrame } from "../catalog";
6
6
  import { catalog, index } from "../data";
7
7
  import { Ident } from "../components/Ident";
8
- import { EditorLink } from "../components/EditorLink";
9
- import { SourcePreviewButton } from "../components/SourcePreview";
8
+ import { SourcePreviewLink } from "../components/SourcePreview";
10
9
  import { flowRepoService } from "../lib/derive";
11
10
  import { sourceLocation } from "../lib/source-link";
12
11
  import { AdrNumber, StatusChip } from "../components/primitives";
13
12
  import { ShapeRows } from "../components/ShapeRows";
14
13
  import { shapeFor } from "../components/MethodRows";
15
- import { stepAnswer } from "./answers";
14
+ import { stepAnswer, stepRpcContract } from "./answers";
16
15
  import {
17
16
  aggregatePath,
18
17
  paths,
@@ -154,12 +153,28 @@ function EventDetail({ step, flow }: { step: Step; flow: Flow }) {
154
153
  }
155
154
 
156
155
  function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
157
- const method = step.ref ?? step.label ?? "(unknown method)";
156
+ const contract = stepRpcContract(index, step);
157
+ const method = contract?.id ?? step.ref ?? step.label ?? "(unknown method)";
158
158
  const call = step.ref ? index.rpcById.get(step.ref) : undefined;
159
- const provider = step.ref
160
- ? index.rpcProviderByMethod.get(step.ref)
159
+ const provider = contract?.provider;
160
+ const internalProvider = provider
161
+ ? index.serviceById.get(provider.id)
161
162
  : undefined;
162
- const providerPath = provider ? servicePath(provider.id) : null;
163
+ const providerPath = internalProvider ? servicePath(internalProvider.id) : null;
164
+ const requestFields = contract
165
+ ? shapeFor(
166
+ contract.provided,
167
+ contract.method.request,
168
+ contract.method.requestRef,
169
+ )
170
+ : null;
171
+ const responseFields = contract
172
+ ? shapeFor(
173
+ contract.provided,
174
+ contract.method.response,
175
+ contract.method.responseRef,
176
+ )
177
+ : null;
163
178
  // The flow records the hop; what comes back is the contract's to say.
164
179
  const answer = stepAnswer(index, step);
165
180
 
@@ -185,10 +200,12 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
185
200
  ) : null}
186
201
  <dt className="mono text-muted">Provider</dt>
187
202
  <dd>
188
- {provider && providerPath ? (
203
+ {internalProvider && providerPath ? (
189
204
  <Link to={providerPath} className="mono text-accent">
190
- {provider.id} →
205
+ {internalProvider.id} →
191
206
  </Link>
207
+ ) : provider ? (
208
+ <span className="mono text-ink">{provider.id}</span>
192
209
  ) : (
193
210
  <span className="mono inline-flex items-center gap-1.5 rounded-control border px-1.5 py-0.5 status-unresolved">
194
211
  <AlertTriangle size={11} aria-hidden />
@@ -205,6 +222,34 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
205
222
  </dl>
206
223
  </DetailSection>
207
224
 
225
+ {contract?.method.request ? (
226
+ <DetailSection title="Request" meta={contract.method.request}>
227
+ {requestFields ? (
228
+ <ShapeRows
229
+ fields={requestFields}
230
+ enums={contract.provided.enums}
231
+ showHeader
232
+ />
233
+ ) : (
234
+ <div className="mono text-muted">shape not recorded</div>
235
+ )}
236
+ </DetailSection>
237
+ ) : null}
238
+
239
+ {contract?.method.response ? (
240
+ <DetailSection title="Response" meta={contract.method.response}>
241
+ {responseFields ? (
242
+ <ShapeRows
243
+ fields={responseFields}
244
+ enums={contract.provided.enums}
245
+ showHeader
246
+ />
247
+ ) : (
248
+ <div className="mono text-muted">shape not recorded</div>
249
+ )}
250
+ </DetailSection>
251
+ ) : null}
252
+
208
253
  <DetailSection title="Source">
209
254
  {call ? (
210
255
  <SourceWhere where={call.source} flow={flow} structured />
@@ -525,48 +570,25 @@ function SourceWhere({
525
570
  allRepos(catalog),
526
571
  );
527
572
 
528
- const actions = (
529
- <>
530
- <SourcePreviewButton
531
- location={location}
532
- className="border px-2 py-1 border-line bg-canvas hover:bg-raised hover:no-underline"
533
- />
534
- {location?.href ? (
535
- <a
536
- href={location.href}
537
- target="_blank"
538
- rel="noreferrer"
539
- className="mono inline-flex items-center rounded-control border px-2 py-1 border-line bg-canvas text-accent hover:bg-raised"
540
- title="Open on the forge, at the built commit"
541
- >
542
- forge ↗
543
- </a>
544
- ) : null}
545
- <EditorLink
546
- location={location}
547
- variant="text"
548
- className="inline-flex items-center border px-2 py-1 border-line bg-canvas hover:bg-raised hover:no-underline"
549
- />
550
- </>
573
+ const source = (
574
+ <SourcePreviewLink
575
+ location={location}
576
+ className={
577
+ structured
578
+ ? "w-full min-w-0 border px-2.5 py-2 border-line bg-canvas text-ink hover:border-line-strong hover:bg-raised hover:no-underline"
579
+ : "max-w-full min-w-0 text-muted hover:text-accent"
580
+ }
581
+ >
582
+ <FileCode2 size={14} aria-hidden className="shrink-0" />
583
+ <span className="min-w-0 break-all text-left">{where}</span>
584
+ </SourcePreviewLink>
551
585
  );
552
586
 
553
587
  if (structured) {
554
- return (
555
- <div className="flex min-w-0 flex-col gap-2">
556
- <div className="mono min-w-0 break-all text-muted">
557
- <Ident block value={where} className="text-muted" />
558
- </div>
559
- <div className="flex flex-wrap items-center gap-1.5">{actions}</div>
560
- </div>
561
- );
588
+ return <div className="min-w-0">{source}</div>;
562
589
  }
563
590
 
564
- return (
565
- <div className="mono flex flex-wrap items-center gap-2 break-all text-muted">
566
- <Ident block value={where} className="text-muted" />
567
- {actions}
568
- </div>
569
- );
591
+ return <div className="min-w-0">{source}</div>;
570
592
  }
571
593
 
572
594
  function Where({ step, flow }: { step: Step; flow: Flow }) {
@@ -574,6 +596,34 @@ function Where({ step, flow }: { step: Step; flow: Flow }) {
574
596
  return <SourceWhere where={step.line} flow={flow} />;
575
597
  }
576
598
 
599
+ function CallDetail({ step, flow }: { step: Step; flow: Flow }) {
600
+ return (
601
+ <section
602
+ aria-label="Call detail"
603
+ className="overflow-hidden rounded-card border shadow-xs border-line"
604
+ >
605
+ <header className="border-b px-3 py-2.5 border-line bg-surface">
606
+ <h2 className="label">Call</h2>
607
+ <div className="mt-2">
608
+ <Ident
609
+ block
610
+ value={step.label ?? "internal call"}
611
+ className="text-ink"
612
+ />
613
+ </div>
614
+ </header>
615
+
616
+ <DetailSection title="Source">
617
+ {step.line ? (
618
+ <SourceWhere where={step.line} flow={flow} structured />
619
+ ) : (
620
+ <div className="mono text-muted">not recorded</div>
621
+ )}
622
+ </DetailSection>
623
+ </section>
624
+ );
625
+ }
626
+
577
627
  function StoreCallDetail({ step, flow }: { step: Step; flow: Flow }) {
578
628
  const access = step.storeAccess!;
579
629
  const store = index.storeById.get(access.store);
@@ -699,13 +749,7 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
699
749
  ) : step.storeAccess ? (
700
750
  <StoreCallDetail step={step} flow={flow} />
701
751
  ) : (
702
- <>
703
- <div className="mono text-[13px]">
704
- {step.label ?? "internal call"}
705
- </div>
706
- <Label>Source</Label>
707
- <Where step={step} flow={flow} />
708
- </>
752
+ <CallDetail step={step} flow={flow} />
709
753
  )}
710
754
 
711
755
  {step.note ? (
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { catalog, index } from "../data";
3
3
  import { walkSteps } from "../catalog";
4
- import { flowAnswers, stepAnswer } from "./answers";
4
+ import { flowAnswers, stepAnswer, stepRpcContract } from "./answers";
5
5
 
6
6
  const flow = (slug: string) => {
7
7
  const found = catalog.flows.find((f) => f.slug === slug);
@@ -16,6 +16,23 @@ const step = (slug: string, id: string) => {
16
16
  };
17
17
 
18
18
  describe("stepAnswer", () => {
19
+ it("resolves an incoming endpoint to its full request and response contract", () => {
20
+ const contract = stepRpcContract(
21
+ index,
22
+ step("pricing-archive-price-list", "s1"),
23
+ );
24
+
25
+ expect(contract?.id).toBe("shop.v1.PriceLists/ArchivePriceList");
26
+ expect(contract?.provider.id).toBe("shop.pricing");
27
+ expect(contract?.method.request).toBe("ArchivePriceListRequest");
28
+ expect(contract?.method.response).toBe("ArchivePriceListResponse");
29
+ expect(
30
+ contract?.provided.messages?.find(
31
+ (message) => message.name === contract.method.request,
32
+ )?.fields.map((field) => field.name),
33
+ ).toEqual(["price_list_id"]);
34
+ });
35
+
19
36
  it("reads the answer of an endpoint off the document that declares it", () => {
20
37
  // billing's ViewSet is exposed by billing.v1.Invoices, and the document
21
38
  // says a void answers 204 and an issue answers with the invoice's id.