@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
@@ -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,14 +90,14 @@ function Header() {
86
90
  {theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
87
91
  </button>
88
92
  <a
89
- href={REPOSITORY}
93
+ href={PRODUCT_REPOSITORY}
90
94
  className="tbtn hidden sm:flex"
91
95
  target="_blank"
92
96
  rel="noreferrer"
93
97
  >
94
98
  <GitBranch size={15} /> GitHub
95
99
  </a>
96
- <Link to={exampleTo} className="btn-accent landing-primary">
100
+ <Link to={exampleTo} className="landing-primary">
97
101
  Explore example <ArrowRight size={14} />
98
102
  </Link>
99
103
  </div>
@@ -315,7 +319,7 @@ export function LandingPage() {
315
319
  >
316
320
  <Link
317
321
  to={exampleTo}
318
- className="btn-accent landing-primary px-4 py-2.5"
322
+ className="landing-primary px-4 py-2.5"
319
323
  >
320
324
  Explore example catalog <ArrowRight size={15} />
321
325
  </Link>
@@ -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={`${REPOSITORY}#plugins`}
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"
@@ -643,12 +647,12 @@ export function LandingPage() {
643
647
  <div className="mt-4 flex flex-wrap gap-3">
644
648
  <Link
645
649
  to={exampleTo}
646
- className="btn-accent landing-primary px-4 py-2.5"
650
+ className="landing-primary px-4 py-2.5"
647
651
  >
648
652
  Open the example <ArrowRight size={15} />
649
653
  </Link>
650
654
  <a
651
- href={`${REPOSITORY}#use-it-in-your-project`}
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"
@@ -715,12 +719,12 @@ export function LandingPage() {
715
719
  <div className="mt-8 flex flex-wrap justify-center gap-3">
716
720
  <Link
717
721
  to={exampleTo}
718
- className="btn-accent landing-primary px-4 py-2.5"
722
+ className="landing-primary px-4 py-2.5"
719
723
  >
720
724
  Explore example catalog <ArrowRight size={15} />
721
725
  </Link>
722
726
  <a
723
- href={REPOSITORY}
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={`${REPOSITORY}#readme`}>
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={`${REPOSITORY}/blob/main/LICENSE`}
749
+ href={PRODUCT_LICENSE}
746
750
  >
747
751
  MIT License
748
752
  </a>
749
- <a className="hover:text-ink" href={REPOSITORY}>
753
+ <a className="hover:text-ink" href={PRODUCT_REPOSITORY}>
750
754
  GitHub
751
755
  </a>
752
756
  </div>
@@ -1,7 +1,7 @@
1
1
  // Every reading the catalog makes of itself, in one list.
2
2
  //
3
3
  // Four readers, each answering a different question: edges that resolve to
4
- // nothing, schemas that name a method the module lacks, tables and columns
4
+ // nothing, producer and consumer schemas that disagree, tables and columns
5
5
  // that disagree with the model, and channels that documents and events do not
6
6
  // agree on. The problems page, the overview and the landing all want the
7
7
  // union, errors first - a boundary leak is not the same kind of news as a
@@ -23,15 +23,13 @@ function auth(): Service {
23
23
 
24
24
  describe("interfaceDeclaring", () => {
25
25
  it("finds the interface a method belongs to", () => {
26
- expect(interfaceDeclaring(auth(), "registerUser")?.id).toBe(
27
- "auth.v1.Users",
28
- );
29
- expect(interfaceDeclaring(auth(), "login")?.id).toBe("auth.v1.Sessions");
26
+ expect(interfaceDeclaring(auth(), "registerUser")?.id).toBe("auth.v1");
27
+ expect(interfaceDeclaring(auth(), "login")?.id).toBe("auth.v1");
30
28
  expect(interfaceDeclaring(auth(), "nothing")).toBeUndefined();
31
29
  });
32
30
 
33
31
  it("writes a method the way the rest of the app writes one", () => {
34
- expect(methodId(auth(), "registerUser")).toBe("auth.v1.Users/registerUser");
32
+ expect(methodId(auth(), "registerUser")).toBe("auth.v1/registerUser");
35
33
  });
36
34
  });
37
35
 
@@ -92,7 +90,7 @@ describe("methods, now that a method is more than a name", () => {
92
90
  const declared = declaredMethods(auth());
93
91
 
94
92
  expect(declared.length).toBe(methodCount(auth()));
95
- expect(declared[0]?.provided.id).toBe("auth.v1.Users");
93
+ expect(declared[0]?.provided.id).toBe("auth.v1");
96
94
  expect(declared.map((d) => d.method.name)).toContain("login");
97
95
  });
98
96
 
@@ -3,6 +3,7 @@ import {
3
3
  buildHref,
4
4
  buildLabel,
5
5
  buildTitle,
6
+ repositoryCommitHref,
6
7
  type BuildInfo,
7
8
  } from "./build-info";
8
9
 
@@ -45,6 +46,34 @@ describe("buildHref", () => {
45
46
  });
46
47
  });
47
48
 
49
+ describe("repositoryCommitHref", () => {
50
+ it("links a GitLab commit through the repository page", () => {
51
+ expect(repositoryCommitHref("abc123", CI)).toBe(
52
+ "https://gitlab.com/acme/portolan/-/commit/abc123",
53
+ );
54
+ });
55
+
56
+ it("links a GitHub commit through the repository page", () => {
57
+ expect(
58
+ repositoryCommitHref("abc123", {
59
+ ...CI,
60
+ repoUrl: "https://github.com/acme/portolan/",
61
+ forge: "github",
62
+ }),
63
+ ).toBe("https://github.com/acme/portolan/commit/abc123");
64
+ });
65
+
66
+ it("does not guess a route for an unknown forge", () => {
67
+ expect(
68
+ repositoryCommitHref("abc123", {
69
+ ...CI,
70
+ repoUrl: "https://code.example.com/acme/portolan",
71
+ forge: undefined,
72
+ }),
73
+ ).toBeNull();
74
+ });
75
+ });
76
+
48
77
  describe("buildLabel", () => {
49
78
  it("reads as the commit, not the build number, even on CI", () => {
50
79
  expect(buildLabel(CI)).toBe("4f1c9ae");
@@ -50,6 +50,27 @@ export function buildHref(info: BuildInfo = buildInfo): string | null {
50
50
  return info.commitUrl || info.buildUrl || null;
51
51
  }
52
52
 
53
+ /** An immutable commit in the repository this bundle was built from. */
54
+ export function repositoryCommitHref(
55
+ commit: string,
56
+ info: BuildInfo = buildInfo,
57
+ ): string | null {
58
+ if (!commit || !info.repoUrl) return null;
59
+
60
+ const repo = info.repoUrl.replace(/\/$/, "");
61
+ const forge =
62
+ info.forge ??
63
+ (/gitlab/i.test(repo)
64
+ ? "gitlab"
65
+ : /github/i.test(repo)
66
+ ? "github"
67
+ : null);
68
+ if (!forge) return null;
69
+
70
+ const route = forge === "gitlab" ? "/-/commit/" : "/commit/";
71
+ return `${repo}${route}${encodeURIComponent(commit)}`;
72
+ }
73
+
53
74
  /**
54
75
  * What the stamp reads: the commit, because that is the thing you can look up
55
76
  * in the repo. The build number is CI bookkeeping and lives in the tooltip.
@@ -15,6 +15,7 @@ import type {
15
15
  Column,
16
16
  ColumnOwner,
17
17
  Field,
18
+ RedisKeyspace,
18
19
  Service,
19
20
  Store,
20
21
  Table,
@@ -128,6 +129,19 @@ export interface PersistedTable {
128
129
  store: Store;
129
130
  }
130
131
 
132
+ export interface PersistedRedisKeyspace {
133
+ keyspace: RedisKeyspace;
134
+ store: Store;
135
+ }
136
+
137
+ /** Redis key families whose serialized value is an aggregate snapshot. */
138
+ export function redisKeyspacesPersisting(
139
+ index: CatalogIndex,
140
+ aggregateId: string,
141
+ ): PersistedRedisKeyspace[] {
142
+ return index.keyspacesByAggregate.get(aggregateId) ?? [];
143
+ }
144
+
131
145
  /**
132
146
  * The tables that hold an aggregate — those naming it in `persists`, plus the
133
147
  * ones naming one of its blocks. A child table pointing at a value object of
package/src/lib/derive.ts CHANGED
@@ -420,6 +420,7 @@ export type ProblemKind =
420
420
  | "column-type"
421
421
  | "outbox-payload"
422
422
  | "proto-missing"
423
+ | "proto-drift"
423
424
  | "shared-channel"
424
425
  | "channel-undeclared"
425
426
  | "channel-unpublished"
@@ -42,7 +42,7 @@ export function flowHealth(flow: Flow): FlowHealth {
42
42
  const steps = walkSteps(flow.steps);
43
43
  const statuses: Status[] = steps.map((s) => s.status);
44
44
  if (statuses.some((s) => s === "unresolved")) return "unresolved";
45
- const hops = steps.filter((s) => s.kind !== "call");
45
+ const hops = steps.filter((s) => s.kind !== "call" && s.kind !== "response");
46
46
  if (hops.length > 0 && hops.every((s) => s.status === "verified"))
47
47
  return "verified";
48
48
  return "declared";
@@ -6,6 +6,7 @@ import {
6
6
  listForgeBranches,
7
7
  listForgeRefs,
8
8
  listGitHubBranches,
9
+ loadForgeCommit,
9
10
  loadForgeCatalog,
10
11
  loadGitHubCatalog,
11
12
  } from "./github-catalog";
@@ -140,6 +141,56 @@ describe("listGitHubBranches", () => {
140
141
  });
141
142
  });
142
143
 
144
+ describe("loadForgeCommit", () => {
145
+ it("normalizes GitHub commit metadata for the hover card", async () => {
146
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
147
+ sha: SHA,
148
+ html_url: `${REPO.webUrl}/commit/${SHA}`,
149
+ commit: {
150
+ message: "Explain the decision\n\nKeep the useful detail.",
151
+ author: { name: "Ada", date: "2026-09-08T10:00:00Z" },
152
+ },
153
+ author: { login: "ada", avatar_url: "https://avatars.example/ada" },
154
+ stats: { additions: 12, deletions: 3 },
155
+ }), { status: 200 }));
156
+ vi.stubGlobal("fetch", fetch);
157
+
158
+ await expect(loadForgeCommit(REPO, SHA, { token: "secret" })).resolves.toEqual({
159
+ sha: SHA,
160
+ title: "Explain the decision",
161
+ body: "Keep the useful detail.",
162
+ author: "ada",
163
+ authoredAt: "2026-09-08T10:00:00Z",
164
+ avatarUrl: "https://avatars.example/ada",
165
+ additions: 12,
166
+ deletions: 3,
167
+ webUrl: `${REPO.webUrl}/commit/${SHA}`,
168
+ });
169
+ expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get("authorization")).toBe("Bearer secret");
170
+ });
171
+
172
+ it("normalizes GitLab commit metadata for the same card", async () => {
173
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
174
+ id: SHA,
175
+ title: "Explain the decision",
176
+ message: "Explain the decision\n\nKeep the useful detail.",
177
+ author_name: "Ada",
178
+ authored_date: "2026-09-08T10:00:00Z",
179
+ web_url: `${GITLAB_REPO.webUrl}/-/commit/${SHA}`,
180
+ stats: { additions: 12, deletions: 3 },
181
+ }), { status: 200 })));
182
+
183
+ await expect(loadForgeCommit(GITLAB_REPO, SHA)).resolves.toMatchObject({
184
+ sha: SHA,
185
+ title: "Explain the decision",
186
+ body: "Keep the useful detail.",
187
+ author: "Ada",
188
+ additions: 12,
189
+ deletions: 3,
190
+ });
191
+ });
192
+ });
193
+
143
194
  describe("loadGitHubCatalog", () => {
144
195
  it("discovers manifest sources at an immutable SHA and validates the merge", async () => {
145
196
  const source = {
@@ -33,6 +33,18 @@ export type ForgeRepo = GitHubRepo | GitLabRepo;
33
33
 
34
34
  export type ForgeAccess = { token?: string };
35
35
 
36
+ export type ForgeCommit = {
37
+ sha: string;
38
+ title: string;
39
+ body: string;
40
+ author: string;
41
+ authoredAt: string;
42
+ avatarUrl: string;
43
+ additions: number | null;
44
+ deletions: number | null;
45
+ webUrl: string;
46
+ };
47
+
36
48
  /**
37
49
  * A name the forge resolves to a commit: a branch head as it is now, or a
38
50
  * tag. Both are what a reader compares against; the kind is kept because a
@@ -263,6 +275,63 @@ function gitlabError(response: Response): Error {
263
275
  return new Error(`GitLab API request failed (${response.status}).`);
264
276
  }
265
277
 
278
+ /** Commit metadata for a hover card, normalized across GitHub and GitLab. */
279
+ export async function loadForgeCommit(
280
+ repo: ForgeRepo,
281
+ sha: string,
282
+ access: ForgeAccess = {},
283
+ ): Promise<ForgeCommit> {
284
+ if (repo.provider === "github") {
285
+ const found = await githubJson<{
286
+ sha: string;
287
+ html_url: string;
288
+ commit: {
289
+ message: string;
290
+ author: { name: string; date: string } | null;
291
+ };
292
+ author: { login: string; avatar_url: string } | null;
293
+ stats?: { additions: number; deletions: number };
294
+ }>(apiUrl(repo, `/commits/${encodeURIComponent(sha)}`), access.token);
295
+ const [title = found.sha.slice(0, 7), ...body] = found.commit.message.split("\n");
296
+ return {
297
+ sha: found.sha,
298
+ title,
299
+ body: body.join("\n").trim(),
300
+ author: found.author?.login || found.commit.author?.name || "unknown",
301
+ authoredAt: found.commit.author?.date || "",
302
+ avatarUrl: found.author?.avatar_url || "",
303
+ additions: found.stats?.additions ?? null,
304
+ deletions: found.stats?.deletions ?? null,
305
+ webUrl: found.html_url,
306
+ };
307
+ }
308
+
309
+ const found = await gitlabJson<{
310
+ id: string;
311
+ title: string;
312
+ message: string;
313
+ author_name: string;
314
+ authored_date: string;
315
+ web_url: string;
316
+ stats?: { additions: number; deletions: number };
317
+ }>(
318
+ gitlabApiUrl(repo, `/repository/commits/${encodeURIComponent(sha)}`),
319
+ access.token,
320
+ );
321
+ const body = found.message.slice(found.title.length).trim();
322
+ return {
323
+ sha: found.id,
324
+ title: found.title,
325
+ body,
326
+ author: found.author_name || "unknown",
327
+ authoredAt: found.authored_date || "",
328
+ avatarUrl: "",
329
+ additions: found.stats?.additions ?? null,
330
+ deletions: found.stats?.deletions ?? null,
331
+ webUrl: found.web_url,
332
+ };
333
+ }
334
+
266
335
  async function fetchText(
267
336
  url: string,
268
337
  label: string,
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { RpcMethod, RpcService } from "../catalog";
3
+ import { grpcRequestJson, grpcurlCommand } from "./grpc-reference";
4
+
5
+ const method: RpcMethod = {
6
+ name: "Create",
7
+ request: "CreateRequest",
8
+ response: "CreateResponse",
9
+ };
10
+
11
+ const provided: RpcService = {
12
+ id: "shop.v1.Orders",
13
+ source: "proto/shop/v1/orders.proto:10",
14
+ methods: [method],
15
+ enums: [
16
+ {
17
+ name: "Priority",
18
+ values: [
19
+ { name: "PRIORITY_UNSPECIFIED", number: 0 },
20
+ { name: "PRIORITY_HIGH", number: 1 },
21
+ ],
22
+ },
23
+ ],
24
+ messages: [
25
+ {
26
+ name: "CreateRequest",
27
+ fields: [
28
+ { name: "name", type: "string", doc: "" },
29
+ { name: "quantity", type: "int64", doc: "" },
30
+ { name: "expedited", type: "bool", doc: "" },
31
+ { name: "priority", type: "Priority", doc: "" },
32
+ { name: "lines", type: "[]Line", doc: "" },
33
+ { name: "when", type: "Timestamp", doc: "" },
34
+ { name: "money", type: "Money", ref: "Money", doc: "" },
35
+ ],
36
+ },
37
+ { name: "CreateResponse", fields: [] },
38
+ ],
39
+ };
40
+
41
+ describe("gRPC reference examples", () => {
42
+ it("builds a ProtoJSON request from scalars, enums, lists and shared defs", () => {
43
+ expect(JSON.parse(grpcRequestJson(provided, method, {
44
+ Money: {
45
+ fields: [
46
+ { name: "currency", type: "string", doc: "" },
47
+ { name: "minor", type: "int64", doc: "" },
48
+ ],
49
+ },
50
+ }))).toEqual({
51
+ name: "",
52
+ quantity: "0",
53
+ expedited: false,
54
+ priority: "PRIORITY_UNSPECIFIED",
55
+ lines: [],
56
+ when: "1970-01-01T00:00:00Z",
57
+ money: { currency: "", minor: "0" },
58
+ });
59
+ });
60
+
61
+ it("emits a ready-to-edit grpcurl command", () => {
62
+ const command = grpcurlCommand(provided, method, {});
63
+ expect(command).toContain("grpcurl -plaintext");
64
+ expect(command).toContain("localhost:50051");
65
+ expect(command).toContain("shop.v1.Orders/Create");
66
+ expect(command).toContain(`"quantity": "0"`);
67
+ });
68
+
69
+ it("falls back to an empty body when no request shape was extracted", () => {
70
+ expect(grpcRequestJson(provided, { name: "Ping" }, {})).toBe("{}");
71
+ });
72
+ });
@@ -0,0 +1,117 @@
1
+ // Small, executable examples for a gRPC method.
2
+ //
3
+ // The protobuf extractor already leaves the catalog with enough information
4
+ // to sketch a request. Keeping that derivation here means the reference UI
5
+ // does not need to understand scalar mappings, recursive messages or shell
6
+ // quoting, and it gives the generated command a focused test surface.
7
+
8
+ import type { Field, RpcMethod, RpcService, TypeDef } from "../catalog";
9
+ import { parseType } from "./shape";
10
+
11
+ const NUMBER = new Set([
12
+ "double",
13
+ "float",
14
+ "int32",
15
+ "sint32",
16
+ "sfixed32",
17
+ "uint32",
18
+ "fixed32",
19
+ ]);
20
+ const LONG = new Set([
21
+ "int64",
22
+ "sint64",
23
+ "sfixed64",
24
+ "uint64",
25
+ "fixed64",
26
+ ]);
27
+
28
+ function shortName(name: string): string {
29
+ return name.split(".").at(-1) ?? name;
30
+ }
31
+
32
+ function fieldsFor(
33
+ provided: RpcService,
34
+ name: string,
35
+ ref: string | undefined,
36
+ defs: Record<string, TypeDef>,
37
+ ): Field[] | null {
38
+ if (ref && defs[ref]) return defs[ref].fields;
39
+ const bare = shortName(name);
40
+ return (
41
+ provided.messages?.find(
42
+ (message) => message.name === name || message.name === bare,
43
+ )?.fields ?? null
44
+ );
45
+ }
46
+
47
+ function valueFor(
48
+ provided: RpcService,
49
+ field: Field,
50
+ defs: Record<string, TypeDef>,
51
+ visiting: ReadonlySet<string>,
52
+ ): unknown {
53
+ const parsed = parseType(field.type);
54
+ if (parsed.cardinality === "many") return [];
55
+ if (parsed.cardinality === "map") return {};
56
+
57
+ const type = shortName(parsed.base);
58
+ if (type === "string" || type === "bytes") return "";
59
+ if (type === "bool") return false;
60
+ if (NUMBER.has(type)) return 0;
61
+ // ProtoJSON writes 64-bit integers as decimal strings so their precision is
62
+ // not lost in JavaScript.
63
+ if (LONG.has(type)) return "0";
64
+ if (type === "Timestamp") return "1970-01-01T00:00:00Z";
65
+ if (type === "Duration") return "0s";
66
+ if (type === "FieldMask") return "";
67
+ if (type === "Struct") return {};
68
+ if (type === "Value") return null;
69
+ if (type === "ListValue") return [];
70
+ if (type === "Empty") return {};
71
+ if (type === "Any") return { "@type": "" };
72
+
73
+ const set = provided.enums?.find(
74
+ (candidate) => candidate.name === parsed.base || candidate.name === type,
75
+ );
76
+ if (set) return set.values[0]?.name ?? "";
77
+
78
+ const nested = fieldsFor(provided, parsed.base, field.ref, defs);
79
+ if (!nested || visiting.has(parsed.base)) return {};
80
+
81
+ const next = new Set(visiting).add(parsed.base);
82
+ return Object.fromEntries(
83
+ nested.map((child) => [child.name, valueFor(provided, child, defs, next)]),
84
+ );
85
+ }
86
+
87
+ /** A valid ProtoJSON-shaped starting point for a method request. */
88
+ export function grpcRequestJson(
89
+ provided: RpcService,
90
+ method: RpcMethod,
91
+ defs: Record<string, TypeDef>,
92
+ ): string {
93
+ if (!method.request) return "{}";
94
+ const fields = fieldsFor(provided, method.request, method.requestRef, defs);
95
+ if (!fields) return "{}";
96
+
97
+ const visiting = new Set([method.request]);
98
+ const value = Object.fromEntries(
99
+ fields.map((field) => [field.name, valueFor(provided, field, defs, visiting)]),
100
+ );
101
+ return JSON.stringify(value, null, 2);
102
+ }
103
+
104
+ /** A copyable local-development invocation. The endpoint is deliberately visible. */
105
+ export function grpcurlCommand(
106
+ provided: RpcService,
107
+ method: RpcMethod,
108
+ defs: Record<string, TypeDef>,
109
+ ): string {
110
+ const body = grpcRequestJson(provided, method, defs).replace(/'/g, `'"'"'`);
111
+ return [
112
+ "grpcurl -plaintext \\",
113
+ ` -d '${body}' \\`,
114
+ " localhost:50051 \\",
115
+ ` ${provided.id}/${method.name}`,
116
+ ].join("\n");
117
+ }