@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
|
@@ -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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
<
|
|
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="
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
<div className="rounded-card border border-line bg-surface px-4 py-3">
|
|
51
|
+
<div className="mono flex flex-wrap items-center gap-x-3">
|
|
52
|
+
<KindIcon kind="module" />
|
|
53
|
+
<span className="chip">gRPC</span>
|
|
54
|
+
<Link
|
|
55
|
+
to={paths.module(module.slug)}
|
|
56
|
+
className="text-ink hover:underline"
|
|
57
|
+
>
|
|
58
|
+
{module.name}
|
|
59
|
+
</Link>
|
|
60
|
+
{module.commit ? (
|
|
61
|
+
<Ident value={module.commit.slice(0, 12)} className="text-muted" />
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
<p className="mt-1.5 text-muted">
|
|
65
|
+
Methods and message shapes extracted from Protocol Buffers. Open a
|
|
66
|
+
method's local call to copy a ProtoJSON body or a grpcurl command.
|
|
67
|
+
</p>
|
|
60
68
|
</div>
|
|
61
69
|
|
|
62
70
|
{declared.length === 0 ? (
|
|
@@ -72,7 +80,14 @@ export function ModuleSpec({ moduleId }: { moduleId: string }) {
|
|
|
72
80
|
<div className="mono border-b px-3 py-1.5 border-line bg-surface">
|
|
73
81
|
<Ident value={provided.id} className="text-ink" />
|
|
74
82
|
</div>
|
|
75
|
-
<MethodRows
|
|
83
|
+
<MethodRows
|
|
84
|
+
provided={provided}
|
|
85
|
+
open={open}
|
|
86
|
+
onToggle={toggle}
|
|
87
|
+
reference={(method) => (
|
|
88
|
+
<GrpcMethodReference provided={provided} method={method} />
|
|
89
|
+
)}
|
|
90
|
+
/>
|
|
76
91
|
<MessageList provided={provided} open={open} onToggle={toggle} />
|
|
77
92
|
</div>
|
|
78
93
|
</section>
|