@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.
Files changed (135) hide show
  1. package/README.md +7 -2
  2. package/cli/init.mjs +14 -6
  3. package/cli/init.test.mjs +8 -1
  4. package/cli/portolan.mjs +92 -13
  5. package/cli/portolan.test.mjs +47 -0
  6. package/package.json +7 -6
  7. package/plugins/extract-django/README.md +1 -1
  8. package/plugins/extract-django/extract_test.py +1 -1
  9. package/plugins/extract-django/openapi.py +4 -14
  10. package/plugins/extract-django/openapi_test.py +4 -4
  11. package/plugins/extract-go/options.schema.json +5 -0
  12. package/plugins/extract-project/options.schema.json +21 -0
  13. package/plugins/extract-ts/README.md +1 -1
  14. package/plugins/extract-ts/clients.ts +1 -1
  15. package/plugins/extract-ts/extract.test.ts +2 -2
  16. package/plugins/extract-ts/graphql.test.ts +2 -2
  17. package/plugins/extract-ts/openapi.test.ts +6 -7
  18. package/plugins/extract-ts/openapi.ts +3 -10
  19. package/plugins/portolan-go.wasm +0 -0
  20. package/public/404-cat-v1.webp +0 -0
  21. package/public/cat-about-v1.webp +0 -0
  22. package/public/cat-clear-skies-v1.webp +0 -0
  23. package/public/cat-no-changes-v1.webp +0 -0
  24. package/public/cat-onboarding-v1.webp +0 -0
  25. package/public/cat-trial-success-v1.webp +0 -0
  26. package/schema/portolan.schema.json +61 -0
  27. package/scripts/builtin-plugins.mjs +3 -2
  28. package/scripts/catalog-sources.mjs +14 -2
  29. package/scripts/catalog-sources.test.mjs +45 -0
  30. package/scripts/delivery-presets.mjs +207 -24
  31. package/scripts/diff.mjs +5 -1
  32. package/scripts/gen-likec4.mjs +242 -85
  33. package/scripts/gen-likec4.test.mjs +168 -14
  34. package/scripts/gen.mjs +5 -3
  35. package/scripts/host-plugins/fetch-git.mjs +22 -12
  36. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  37. package/scripts/local-api.mjs +330 -346
  38. package/scripts/local-api.test.mjs +245 -1
  39. package/scripts/local-discovery.mjs +378 -0
  40. package/scripts/manifest.mjs +42 -1
  41. package/scripts/manifest.test.mjs +24 -1
  42. package/scripts/plugin-host.mjs +14 -3
  43. package/scripts/plugin-host.test.mjs +8 -0
  44. package/scripts/run-builtin.mjs +4 -2
  45. package/scripts/schema.mjs +11 -0
  46. package/scripts/site-docs.mjs +2 -2
  47. package/src/app/CatalogApp.tsx +23 -8
  48. package/src/app/CommandPalette.tsx +7 -2
  49. package/src/app/Sidebar.tsx +13 -730
  50. package/src/app/SidebarFlowSections.tsx +251 -0
  51. package/src/app/SidebarFooter.tsx +160 -0
  52. package/src/app/SidebarTree.tsx +322 -0
  53. package/src/app/toast.tsx +11 -7
  54. package/src/catalog-index.ts +485 -0
  55. package/src/catalog-model.ts +1339 -0
  56. package/src/catalog-validation.ts +1570 -0
  57. package/src/catalog.test.ts +49 -0
  58. package/src/catalog.ts +6 -3124
  59. package/src/components/CatIllustration.tsx +30 -0
  60. package/src/components/CommitLink.tsx +154 -0
  61. package/src/components/EditorLink.tsx +15 -8
  62. package/src/components/GrpcMethodReference.tsx +93 -0
  63. package/src/components/Markdown.tsx +17 -0
  64. package/src/components/MethodRows.tsx +8 -1
  65. package/src/components/PageHeader.test.tsx +23 -0
  66. package/src/components/PageHeader.tsx +34 -0
  67. package/src/components/ProblemRow.tsx +3 -0
  68. package/src/components/ShapeRows.tsx +12 -0
  69. package/src/components/SourceDoc.tsx +27 -12
  70. package/src/components/SourcePreview.tsx +125 -37
  71. package/src/enrich.test.ts +209 -2
  72. package/src/enrich.ts +284 -15
  73. package/src/er/RedisSchema.tsx +86 -5
  74. package/src/flow/FlowTable.tsx +19 -4
  75. package/src/flow/StepDetail.tsx +461 -101
  76. package/src/flow/StepRail.tsx +36 -13
  77. package/src/flow/answers-response.test.ts +100 -0
  78. package/src/flow/answers.ts +11 -8
  79. package/src/flow/chapters.ts +2 -1
  80. package/src/flow/labels.ts +10 -0
  81. package/src/flow/mermaid.test.ts +87 -3
  82. package/src/flow/mermaid.ts +26 -14
  83. package/src/index.css +132 -17
  84. package/src/landing/LandingPage.tsx +16 -12
  85. package/src/lib/all-problems.ts +1 -1
  86. package/src/lib/api.test.ts +4 -6
  87. package/src/lib/build-info.test.ts +29 -0
  88. package/src/lib/build-info.ts +21 -0
  89. package/src/lib/data-model.ts +14 -0
  90. package/src/lib/derive.ts +1 -0
  91. package/src/lib/flow-tree.ts +1 -1
  92. package/src/lib/github-catalog.test.ts +51 -0
  93. package/src/lib/github-catalog.ts +69 -0
  94. package/src/lib/grpc-reference.test.ts +72 -0
  95. package/src/lib/grpc-reference.ts +117 -0
  96. package/src/lib/local-api.ts +36 -11
  97. package/src/lib/motion.test.ts +4 -2
  98. package/src/lib/motion.tsx +5 -4
  99. package/src/lib/product.ts +7 -0
  100. package/src/lib/proto-problems.test.ts +170 -3
  101. package/src/lib/proto-problems.ts +176 -4
  102. package/src/lib/queries.ts +16 -1
  103. package/src/lib/readme-assets.test.ts +19 -0
  104. package/src/lib/readme-assets.ts +34 -0
  105. package/src/lib/setup-info.test.ts +7 -0
  106. package/src/lib/setup-info.ts +5 -0
  107. package/src/lib/source-link.test.ts +106 -18
  108. package/src/lib/source-link.ts +22 -6
  109. package/src/merge.test.ts +101 -0
  110. package/src/merge.ts +65 -1
  111. package/src/pages/AdrDetail.tsx +3 -2
  112. package/src/pages/AdrIndex.tsx +10 -7
  113. package/src/pages/AggregatePage.tsx +34 -4
  114. package/src/pages/BlockPage.tsx +39 -32
  115. package/src/pages/Changes.tsx +7 -9
  116. package/src/pages/ContextMap.tsx +11 -2
  117. package/src/pages/ContextPage.tsx +11 -4
  118. package/src/pages/FlowIndex.tsx +16 -7
  119. package/src/pages/GraphPage.tsx +21 -7
  120. package/src/pages/Language.tsx +9 -9
  121. package/src/pages/NotFound.tsx +58 -15
  122. package/src/pages/Overview.tsx +6 -0
  123. package/src/pages/Problems.tsx +10 -8
  124. package/src/pages/RegistryIndex.tsx +10 -8
  125. package/src/pages/ServicePage.tsx +1 -1
  126. package/src/pages/Settings.tsx +257 -44
  127. package/src/pages/settings/AboutSettings.tsx +129 -0
  128. package/src/pages/settings/DeliverySettings.tsx +71 -15
  129. package/src/routes.test.ts +2 -0
  130. package/src/routes.ts +2 -1
  131. package/src/selection/DetailPanel.tsx +213 -113
  132. package/src/selection/pages.test.ts +14 -1
  133. package/src/selection/pages.ts +9 -3
  134. package/src/testing/setup.ts +14 -0
  135. package/vite.config.ts +3 -1
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type CatScene = "about" | "clear" | "unchanged" | "onboarding" | "search" | "trial";
4
+
5
+ const IMAGE: Record<CatScene, string> = {
6
+ about: "cat-about-v1.webp",
7
+ clear: "cat-clear-skies-v1.webp",
8
+ unchanged: "cat-no-changes-v1.webp",
9
+ onboarding: "cat-onboarding-v1.webp",
10
+ search: "404-cat-v1.webp",
11
+ trial: "cat-trial-success-v1.webp",
12
+ };
13
+
14
+ export function CatIllustration({ scene, className = "" }: { scene: CatScene; className?: string }) {
15
+ return <img src={`${import.meta.env.BASE_URL}${IMAGE[scene]}`} alt="" aria-hidden draggable={false} className={`cat-illustration ${className}`} />;
16
+ }
17
+
18
+ /** A rare, positive empty state. Ordinary missing rows keep using `Empty`. */
19
+ export function CatEmptyState({ scene, title, children, meta, className = "" }: { scene: Extract<CatScene, "clear" | "unchanged" | "onboarding">; title: string; children: ReactNode; meta?: ReactNode; className?: string }) {
20
+ return (
21
+ <div className={`cat-empty-state ${className}`}>
22
+ <CatIllustration scene={scene} className="cat-empty-illustration" />
23
+ <div className="min-w-0">
24
+ <h2 className="text-md font-semibold text-ink">{title}</h2>
25
+ <div className="mt-1 text-muted">{children}</div>
26
+ {meta ? <div className="mono mt-3 text-faint">{meta}</div> : null}
27
+ </div>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,154 @@
1
+ import { useState } from "react";
2
+ import {
3
+ FloatingPortal,
4
+ autoUpdate,
5
+ flip,
6
+ offset,
7
+ shift,
8
+ useDismiss,
9
+ useFloating,
10
+ useFocus,
11
+ useHover,
12
+ useInteractions,
13
+ useRole,
14
+ } from "@floating-ui/react";
15
+ import { useQuery } from "@tanstack/react-query";
16
+ import { useForgeAccess } from "../app/forge-access";
17
+ import { buildInfo, repositoryCommitHref } from "../lib/build-info";
18
+ import { forgeRepoFromUrl } from "../lib/github-catalog";
19
+ import type { ForgeCommit, ForgeRepo } from "../lib/github-catalog";
20
+ import { forgeCommitQuery } from "../lib/queries";
21
+ import { absoluteTime, relativeTime } from "../lib/format";
22
+ import { Ident } from "./Ident";
23
+
24
+ const buildRepo = forgeRepoFromUrl(buildInfo.repoUrl, buildInfo.forge);
25
+
26
+ function repoFrom(value: string): ForgeRepo | null {
27
+ const url = /^https?:\/\//.test(value) ? value : `https://${value}`;
28
+ return forgeRepoFromUrl(url);
29
+ }
30
+
31
+ function commitHref(repo: ForgeRepo, commit: string): string {
32
+ const route = repo.provider === "gitlab" ? "/-/commit/" : "/commit/";
33
+ return `${repo.webUrl.replace(/\/$/, "")}${route}${encodeURIComponent(commit)}`;
34
+ }
35
+
36
+ export function CommitLink({
37
+ commit,
38
+ repo,
39
+ repository,
40
+ href,
41
+ length = 7,
42
+ className = "",
43
+ }: {
44
+ commit: string;
45
+ /** Pass null when the repository is known to be unavailable. */
46
+ repo?: ForgeRepo | null;
47
+ /** A repository URL or host/owner/name, used when a ForgeRepo is not already available. */
48
+ repository?: string;
49
+ href?: string | null;
50
+ length?: number;
51
+ className?: string;
52
+ }) {
53
+ const parsed = repository === undefined ? undefined : repoFrom(repository);
54
+ const resolvedRepo = repo !== undefined ? repo : parsed !== undefined ? parsed : buildRepo;
55
+ const hasExplicitRepo = repo !== undefined || repository !== undefined;
56
+ const resolvedHref = href !== undefined
57
+ ? href
58
+ : resolvedRepo
59
+ ? commitHref(resolvedRepo, commit)
60
+ : hasExplicitRepo
61
+ ? null
62
+ : repositoryCommitHref(commit);
63
+ const label = commit.slice(0, length);
64
+
65
+ if (!resolvedHref) return <Ident value={commit} className={className}>{label}</Ident>;
66
+ if (!resolvedRepo) {
67
+ return <a href={resolvedHref} target="_blank" rel="noreferrer" className={`rounded-[4px] text-accent underline-offset-4 hover:underline ${className}`}>{label}</a>;
68
+ }
69
+
70
+ return <CommitPopover commit={commit} repo={resolvedRepo} href={resolvedHref} label={label} className={className} />;
71
+ }
72
+
73
+ function CommitPopover({ commit, repo, href, label, className }: { commit: string; repo: ForgeRepo; href: string; label: string; className: string }) {
74
+ const [open, setOpen] = useState(false);
75
+ const { refs, floatingStyles, context } = useFloating({
76
+ open,
77
+ onOpenChange: setOpen,
78
+ placement: "bottom-start",
79
+ strategy: "fixed",
80
+ middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })],
81
+ whileElementsMounted: autoUpdate,
82
+ });
83
+ const hover = useHover(context, { move: false, delay: { open: 100, close: 80 } });
84
+ const focus = useFocus(context);
85
+ const dismiss = useDismiss(context);
86
+ const role = useRole(context, { role: "tooltip" });
87
+ const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
88
+ const access = useForgeAccess();
89
+ const token = access.tokenFor(repo);
90
+ const details = useQuery({
91
+ ...forgeCommitQuery(repo, token, commit),
92
+ enabled: open,
93
+ });
94
+
95
+ return (
96
+ <>
97
+ <a
98
+ ref={refs.setReference}
99
+ href={href}
100
+ target="_blank"
101
+ rel="noreferrer"
102
+ aria-label={`Open commit ${commit} on the forge`}
103
+ className={`rounded-[4px] text-accent underline-offset-4 hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent ${className}`}
104
+ {...getReferenceProps()}
105
+ >
106
+ {label}
107
+ </a>
108
+ {open ? (
109
+ <FloatingPortal>
110
+ <div
111
+ ref={refs.setFloating}
112
+ style={floatingStyles}
113
+ className="z-50"
114
+ {...getFloatingProps()}
115
+ >
116
+ <div className="palette-in pointer-events-none w-80 max-w-[calc(100vw-1rem)] rounded-control border bg-canvas p-3 mono text-xs normal-case border-line-strong text-muted shadow-md">
117
+ <div className="label">commit</div>
118
+ {details.isPending ? (
119
+ <div className="mt-2">loading commit…</div>
120
+ ) : details.isError ? (
121
+ <div className="mt-2">commit details unavailable</div>
122
+ ) : (
123
+ <CommitHoverContent commit={details.data} />
124
+ )}
125
+ </div>
126
+ </div>
127
+ </FloatingPortal>
128
+ ) : null}
129
+ </>
130
+ );
131
+ }
132
+
133
+ function CommitHoverContent({ commit }: { commit: ForgeCommit }) {
134
+ const hasStats = commit.additions !== null || commit.deletions !== null;
135
+ const body = commit.body.replace(/\s+/g, " ").trim();
136
+ const summary = body.length > 180 ? `${body.slice(0, 177).trimEnd()}…` : body;
137
+ return (
138
+ <div className="mt-2">
139
+ <div className="flex items-center gap-2">
140
+ {commit.avatarUrl ? <img src={commit.avatarUrl} alt="" className="size-7 rounded-full" /> : null}
141
+ <div className="min-w-0">
142
+ <div className="truncate text-ink">{commit.author}</div>
143
+ {commit.authoredAt ? <div className="text-faint" title={absoluteTime(commit.authoredAt)}>{relativeTime(commit.authoredAt)}</div> : null}
144
+ </div>
145
+ </div>
146
+ <div className="mt-2 font-sans font-medium text-ink">{commit.title}</div>
147
+ {summary ? <div className="mt-1 font-sans text-muted">{summary}</div> : null}
148
+ <div className="mt-2 flex items-center gap-3 border-t pt-2 border-line text-faint">
149
+ <span className="truncate" title={commit.sha}>{commit.sha}</span>
150
+ {hasStats ? <span className="ml-auto shrink-0"><span className="text-verified">+{commit.additions ?? 0}</span>{" "}<span className="text-unresolved">−{commit.deletions ?? 0}</span></span> : null}
151
+ </div>
152
+ </div>
153
+ );
154
+ }
@@ -44,6 +44,18 @@ export function useWorkspace(): string | null {
44
44
  : null;
45
45
  }
46
46
 
47
+ export function useEditorTarget(location: SourceLocation | null): {
48
+ href: string;
49
+ name: string;
50
+ } | null {
51
+ const workspace = useWorkspace();
52
+ const editor = useEditorStore((s) => s.editor);
53
+ if (!workspace || !location) return null;
54
+ const where = editorWhere(location);
55
+ const href = where ? editorHref(editor, workspace, where) : null;
56
+ return href ? { href, name: editorName(editor) } : null;
57
+ }
58
+
47
59
  export function EditorLink({
48
60
  location,
49
61
  variant = "icon",
@@ -54,14 +66,9 @@ export function EditorLink({
54
66
  variant?: "icon" | "text";
55
67
  className?: string;
56
68
  }) {
57
- const workspace = useWorkspace();
58
- const editor = useEditorStore((s) => s.editor);
59
- if (!workspace || !location) return null;
60
- const where = editorWhere(location);
61
- const href = where ? editorHref(editor, workspace, where) : null;
62
- if (!href) return null;
63
-
64
- const name = editorName(editor);
69
+ const target = useEditorTarget(location);
70
+ if (!target) return null;
71
+ const { href, name } = target;
65
72
  // No target: a scheme URL hands off to the editor and leaves the page
66
73
  // where it is. A new tab would be a blank tab.
67
74
  return variant === "icon" ? (
@@ -0,0 +1,93 @@
1
+ import { Check, Copy, Terminal } from "lucide-react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import type { RpcMethod, RpcService } from "../catalog";
4
+ import { catalog } from "../data";
5
+ import { toClipboard } from "../lib/clipboard";
6
+ import {
7
+ grpcRequestJson,
8
+ grpcurlCommand,
9
+ } from "../lib/grpc-reference";
10
+
11
+ const SHOWN_MS = 1000;
12
+
13
+ function CopyButton({ value, label }: { value: string; label: string }) {
14
+ const [copied, setCopied] = useState(false);
15
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
16
+
17
+ useEffect(
18
+ () => () => {
19
+ if (timer.current) clearTimeout(timer.current);
20
+ },
21
+ [],
22
+ );
23
+
24
+ const copy = () => {
25
+ void toClipboard(value).then((ok) => {
26
+ setCopied(ok);
27
+ if (timer.current) clearTimeout(timer.current);
28
+ timer.current = setTimeout(() => setCopied(false), SHOWN_MS);
29
+ });
30
+ };
31
+
32
+ return (
33
+ <button
34
+ type="button"
35
+ onClick={copy}
36
+ className="row-action opacity-100"
37
+ aria-label={`Copy ${label}`}
38
+ title={`Copy ${label}`}
39
+ >
40
+ {copied ? <Check size={12} aria-hidden /> : <Copy size={12} aria-hidden />}
41
+ {copied ? "copied" : "copy"}
42
+ </button>
43
+ );
44
+ }
45
+
46
+ /** The executable-looking part of a protobuf reference, collapsed per method. */
47
+ export function GrpcMethodReference({
48
+ provided,
49
+ method,
50
+ }: {
51
+ provided: RpcService;
52
+ method: RpcMethod;
53
+ }) {
54
+ const json = grpcRequestJson(provided, method, catalog.defs);
55
+ const command = grpcurlCommand(provided, method, catalog.defs);
56
+
57
+ return (
58
+ <details className="group mt-2 overflow-hidden rounded-control border border-line bg-surface">
59
+ <summary className="flex cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-muted hover:text-ink">
60
+ <Terminal size={13} aria-hidden />
61
+ <span>Call locally</span>
62
+ <span className="chip mono">grpcurl</span>
63
+ <span className="ml-auto text-faint group-open:hidden">show</span>
64
+ <span className="ml-auto hidden text-faint group-open:inline">hide</span>
65
+ </summary>
66
+ <div className="grid gap-3 border-t border-line p-3 lg:grid-cols-2">
67
+ <div className="min-w-0">
68
+ <div className="mb-1.5 flex items-center justify-between gap-2">
69
+ <span className="label">ProtoJSON request</span>
70
+ <CopyButton value={json} label="request JSON" />
71
+ </div>
72
+ <pre className="mono max-h-64 overflow-auto whitespace-pre rounded-control border border-line bg-canvas p-3 text-muted">
73
+ {json}
74
+ </pre>
75
+ </div>
76
+ <div className="min-w-0">
77
+ <div className="mb-1.5 flex items-center justify-between gap-2">
78
+ <span className="label">Command</span>
79
+ <CopyButton value={command} label="grpcurl command" />
80
+ </div>
81
+ <pre className="mono max-h-64 overflow-auto whitespace-pre rounded-control border border-line bg-canvas p-3 text-muted">
82
+ {command}
83
+ </pre>
84
+ <p className="mt-2 text-muted">
85
+ Replace <span className="mono text-ink">localhost:50051</span> and
86
+ remove <span className="mono text-ink">-plaintext</span> when the
87
+ endpoint uses TLS.
88
+ </p>
89
+ </div>
90
+ </div>
91
+ </details>
92
+ );
93
+ }
@@ -17,6 +17,7 @@ import { headingSlug } from "../lib/derive";
17
17
  import { Mermaid } from "./Mermaid";
18
18
  import { MarkdownTable } from "./MarkdownTable";
19
19
  import { remarkTableDirective } from "../lib/table-directive";
20
+ import { readmeAssetHref } from "../lib/readme-assets";
20
21
 
21
22
  function textOf(node: React.ReactNode): string {
22
23
  if (typeof node === "string" || typeof node === "number") return String(node);
@@ -146,9 +147,11 @@ function Pre(props: ComponentPropsWithoutRef<"pre">) {
146
147
  export function Markdown({
147
148
  children,
148
149
  mermaid = false,
150
+ sourceRoot,
149
151
  }: {
150
152
  children: string;
151
153
  mermaid?: boolean;
154
+ sourceRoot?: string;
152
155
  }) {
153
156
  return (
154
157
  <div className="prose">
@@ -164,6 +167,20 @@ export function Markdown({
164
167
  // said not to.
165
168
  table: MarkdownTable,
166
169
  ...(mermaid ? { pre: Pre } : {}),
170
+ ...(sourceRoot ? {
171
+ a: (props: ComponentPropsWithoutRef<"a">) => {
172
+ const href = typeof props.href === "string"
173
+ ? readmeAssetHref(sourceRoot, props.href, import.meta.env.BASE_URL)
174
+ : null;
175
+ return <a {...props} href={href ?? props.href} {...(href ? { target: "_blank", rel: "noreferrer" } : {})} />;
176
+ },
177
+ img: (props: ComponentPropsWithoutRef<"img">) => {
178
+ const src = typeof props.src === "string"
179
+ ? readmeAssetHref(sourceRoot, props.src, import.meta.env.BASE_URL)
180
+ : null;
181
+ return <img {...props} src={src ?? props.src} />;
182
+ },
183
+ } : {}),
167
184
  }}
168
185
  >
169
186
  {children}
@@ -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
- <RowActions copy={id} />
248
+ <span className="self-start pt-0.5">
249
+ <RowActions copy={id} />
250
+ </span>
244
251
  </li>
245
252
  );
246
253
  })}
@@ -0,0 +1,23 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { CapabilityEmpty } from "./PageHeader";
5
+
6
+ describe("CapabilityEmpty", () => {
7
+ it("keeps the explanation, action and evidence signal together", () => {
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
+ );
17
+
18
+ expect(markup).toContain("next useful step");
19
+ expect(markup).toContain("No dependency graph yet");
20
+ expect(markup).toContain("Review extraction");
21
+ expect(markup).toContain("Signals: domain events and consumers.");
22
+ });
23
+ });
@@ -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
  *
@@ -27,6 +27,7 @@ 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",
@@ -44,6 +45,7 @@ const KIND_NOTE: Record<Problem["kind"], string> = {
44
45
  "outbox-payload": "an outbox with no payload column",
45
46
  "proto-missing":
46
47
  "the provider is in the catalog but answers on no such method",
48
+ "proto-drift": "the vendored proto and provider schema disagree",
47
49
  "shared-channel": "a second service publishes on this channel",
48
50
  "channel-undeclared":
49
51
  "this event goes out on a channel the service does not declare",
@@ -60,6 +62,7 @@ function nearPath(problem: Problem): string | null {
60
62
  // peer is nobody; this is a call whose peer is known and whose method is
61
63
  // not - and in both the thing to go and look at is the caller.
62
64
  case "proto-missing":
65
+ case "proto-drift":
63
66
  return servicePath(problem.service);
64
67
  case "consumer":
65
68
  case "channel-undeclared":
@@ -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="mono flex flex-wrap items-center gap-x-3">
50
- <KindIcon kind="module" />
51
- <Link
52
- to={paths.module(module.slug)}
53
- className="text-ink hover:underline"
54
- >
55
- {module.name}
56
- </Link>
57
- {module.commit ? (
58
- <Ident value={module.commit.slice(0, 12)} className="text-muted" />
59
- ) : null}
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&apos;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 provided={provided} open={open} onToggle={toggle} />
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>