@shortlink-org/portolan 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +368 -34
  34. package/scripts/local-api.test.mjs +197 -2
  35. package/scripts/package-smoke.mjs +19 -2
  36. package/scripts/plugin-host.mjs +14 -3
  37. package/scripts/plugin-host.test.mjs +8 -0
  38. package/scripts/schema.mjs +7 -0
  39. package/src/app/CatalogApp.tsx +19 -4
  40. package/src/app/CommandPalette.tsx +7 -2
  41. package/src/app/toast.tsx +11 -7
  42. package/src/catalog.test.ts +33 -0
  43. package/src/catalog.ts +190 -8
  44. package/src/components/CatIllustration.tsx +30 -0
  45. package/src/components/CommitLink.tsx +154 -0
  46. package/src/components/EditorLink.tsx +15 -8
  47. package/src/components/GrpcMethodReference.tsx +93 -0
  48. package/src/components/Markdown.tsx +17 -0
  49. package/src/components/MethodRows.tsx +8 -1
  50. package/src/components/PageHeader.test.ts +24 -0
  51. package/src/components/PageHeader.tsx +34 -0
  52. package/src/components/ShapeRows.tsx +12 -0
  53. package/src/components/SourceDoc.tsx +27 -12
  54. package/src/components/SourcePreview.tsx +124 -36
  55. package/src/enrich.test.ts +209 -2
  56. package/src/enrich.ts +284 -15
  57. package/src/er/RedisSchema.tsx +86 -5
  58. package/src/flow/FlowTable.tsx +19 -4
  59. package/src/flow/StepDetail.tsx +461 -101
  60. package/src/flow/StepRail.tsx +36 -13
  61. package/src/flow/answers-response.test.ts +100 -0
  62. package/src/flow/answers.ts +11 -8
  63. package/src/flow/chapters.ts +2 -1
  64. package/src/flow/labels.ts +10 -0
  65. package/src/flow/mermaid.test.ts +87 -3
  66. package/src/flow/mermaid.ts +26 -14
  67. package/src/index.css +132 -0
  68. package/src/landing/LandingPage.tsx +12 -8
  69. package/src/lib/api.test.ts +4 -6
  70. package/src/lib/build-info.test.ts +29 -0
  71. package/src/lib/build-info.ts +21 -0
  72. package/src/lib/data-model.ts +14 -0
  73. package/src/lib/flow-tree.ts +1 -1
  74. package/src/lib/github-catalog.test.ts +51 -0
  75. package/src/lib/github-catalog.ts +69 -0
  76. package/src/lib/grpc-reference.test.ts +72 -0
  77. package/src/lib/grpc-reference.ts +117 -0
  78. package/src/lib/local-api.ts +17 -4
  79. package/src/lib/product.ts +7 -0
  80. package/src/lib/queries.ts +16 -1
  81. package/src/lib/readme-assets.test.ts +19 -0
  82. package/src/lib/readme-assets.ts +34 -0
  83. package/src/lib/setup-info.test.ts +7 -0
  84. package/src/lib/setup-info.ts +5 -0
  85. package/src/lib/source-link.test.ts +106 -18
  86. package/src/lib/source-link.ts +22 -6
  87. package/src/merge.test.ts +55 -0
  88. package/src/merge.ts +30 -0
  89. package/src/pages/AdrDetail.tsx +3 -2
  90. package/src/pages/AdrIndex.tsx +10 -7
  91. package/src/pages/AggregatePage.tsx +34 -4
  92. package/src/pages/BlockPage.tsx +39 -32
  93. package/src/pages/Changes.tsx +7 -9
  94. package/src/pages/ContextMap.tsx +11 -2
  95. package/src/pages/ContextPage.tsx +11 -4
  96. package/src/pages/FlowIndex.tsx +16 -7
  97. package/src/pages/GraphPage.tsx +21 -7
  98. package/src/pages/Language.tsx +9 -9
  99. package/src/pages/NotFound.tsx +58 -15
  100. package/src/pages/Overview.tsx +6 -0
  101. package/src/pages/Problems.tsx +10 -8
  102. package/src/pages/RegistryIndex.tsx +10 -8
  103. package/src/pages/ServicePage.tsx +1 -1
  104. package/src/pages/Settings.tsx +382 -44
  105. package/src/routes.test.ts +2 -0
  106. package/src/routes.ts +2 -1
  107. package/src/selection/DetailPanel.tsx +213 -113
  108. package/src/testing/setup.ts +14 -0
  109. package/vite.config.ts +5 -1
package/src/catalog.ts CHANGED
@@ -705,12 +705,7 @@ export interface Store {
705
705
  }
706
706
 
707
707
  export type RedisOperation =
708
- | "read"
709
- | "write"
710
- | "delete"
711
- | "exists"
712
- | "expire"
713
- | "count";
708
+ "read" | "write" | "delete" | "exists" | "expire" | "count";
714
709
 
715
710
  export const REDIS_OPERATIONS: readonly RedisOperation[] = [
716
711
  "read",
@@ -730,6 +725,19 @@ export interface RedisKeyspace {
730
725
  /** Value type where a write or marshal call proves it. */
731
726
  value?: string;
732
727
  source?: string;
728
+ /** Aggregate or block whose value this key family holds, when provable. */
729
+ persists?: { aggregate?: string; block?: string };
730
+ /** Individual client calls, before they are folded into `operations`. */
731
+ accesses?: RedisAccess[];
732
+ }
733
+
734
+ export interface RedisAccess {
735
+ operation: RedisOperation;
736
+ /** Enclosing adapter method, for example `Store.Get`. */
737
+ method?: string;
738
+ ttl?: string;
739
+ value?: string;
740
+ source?: string;
733
741
  }
734
742
 
735
743
  /**
@@ -880,18 +888,37 @@ export interface Step {
880
888
  id: string;
881
889
  from: string;
882
890
  to: string; // participant ids; from === to is a self-message
883
- kind: "rpc" | "event" | "call";
891
+ kind: "rpc" | "event" | "call" | "response";
884
892
  ref?: string; // Event.id or RpcCall.id - resolvable, or status must be unresolved
885
893
  label?: string;
886
894
  status: Status;
887
895
  note?: string;
888
896
  line?: string;
897
+ /** Synchronous request step this synthesized response returns from. */
898
+ replyTo?: string;
899
+ /** Proven HTTP wire contract for a response step. */
900
+ http?: HTTPResponse;
889
901
  /** Source function execution enters here, when an extractor can prove it. */
890
902
  continuesAt?: string;
891
903
  /** Source functions proven to execute on the path represented by this step. */
892
904
  reaches?: string[];
893
905
  /** Exact asynchronous send/receive evidence used for flow composition. */
894
906
  handoff?: FlowHandoff;
907
+ /** Repository call resolved to a concrete store operation after merge. */
908
+ storeAccess?: FlowStoreAccess;
909
+ }
910
+ export interface HTTPResponse {
911
+ status?: number;
912
+ contentType?: string;
913
+ body?: string;
914
+ /** RPC method whose response value is serialized into this body. */
915
+ bodyRef?: string;
916
+ encoding?: string;
917
+ outcome?: "success" | "error";
918
+ warning?: string;
919
+ source?: string;
920
+ /** Shape recovered directly from a literal response body. */
921
+ fields?: Field[];
895
922
  }
896
923
  export interface FlowHandoff {
897
924
  kind: "message" | "job";
@@ -900,6 +927,14 @@ export interface FlowHandoff {
900
927
  message?: string;
901
928
  direction: "send" | "receive";
902
929
  }
930
+ export interface FlowStoreAccess {
931
+ store: string;
932
+ method?: string;
933
+ operation?: RedisOperation;
934
+ keyspace?: string;
935
+ /** Concrete adapter call rather than the use-case-side repository call. */
936
+ source?: string;
937
+ }
903
938
  export interface Parallel {
904
939
  type: "parallel";
905
940
  id: string;
@@ -1410,6 +1445,8 @@ export interface CatalogIndex {
1410
1445
  tablesByAggregate: Map<string, Table[]>;
1411
1446
  /** aggregate id -> views naming it in `persists`, in catalog order */
1412
1447
  viewsByAggregate: Map<string, View[]>;
1448
+ /** aggregate id -> Redis key families holding it, in catalog order */
1449
+ keyspacesByAggregate: Map<string, RedisKeyspaceOwner[]>;
1413
1450
  /** block id -> columns whose `maps` path lands in that block */
1414
1451
  columnsByBlock: Map<string, ColumnOwner[]>;
1415
1452
  /** table id -> the columns pointing at it through a foreign key */
@@ -1444,6 +1481,11 @@ export interface InterfaceOwner {
1444
1481
  provided: RpcService;
1445
1482
  }
1446
1483
 
1484
+ export interface RedisKeyspaceOwner {
1485
+ keyspace: RedisKeyspace;
1486
+ store: Store;
1487
+ }
1488
+
1447
1489
  export function buildIndex(catalog: Catalog): CatalogIndex {
1448
1490
  const serviceById = new Map<string, Service>();
1449
1491
  const serviceContext = new Map<string, BoundedContext>();
@@ -1492,6 +1534,7 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
1492
1534
  const storesOwnedBy = new Map<string, Store[]>();
1493
1535
  const tablesByAggregate = new Map<string, Table[]>();
1494
1536
  const viewsByAggregate = new Map<string, View[]>();
1537
+ const keyspacesByAggregate = new Map<string, RedisKeyspaceOwner[]>();
1495
1538
  const columnsByBlock = new Map<string, ColumnOwner[]>();
1496
1539
  const fkIntoTable = new Map<string, ColumnOwner[]>();
1497
1540
 
@@ -1587,6 +1630,14 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
1587
1630
  owned.push(store);
1588
1631
  storesOwnedBy.set(store.owner, owned);
1589
1632
 
1633
+ for (const keyspace of store.keyspaces ?? []) {
1634
+ const aggregateId = keyspace.persists?.aggregate;
1635
+ if (!aggregateId) continue;
1636
+ const list = keyspacesByAggregate.get(aggregateId) ?? [];
1637
+ list.push({ keyspace, store });
1638
+ keyspacesByAggregate.set(aggregateId, list);
1639
+ }
1640
+
1590
1641
  for (const table of store.tables) {
1591
1642
  tableById.set(table.id, { table, store });
1592
1643
  // A table may name only the block it holds. That block belongs to an
@@ -1720,6 +1771,7 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
1720
1771
  servicesUsingModule,
1721
1772
  tablesByAggregate,
1722
1773
  viewsByAggregate,
1774
+ keyspacesByAggregate,
1723
1775
  columnsByBlock,
1724
1776
  fkIntoTable,
1725
1777
  };
@@ -1839,6 +1891,7 @@ export function validateCatalog(catalog: Catalog): Catalog {
1839
1891
 
1840
1892
  const eventIds = new Set<string>();
1841
1893
  const rpcIds = new Set<string>();
1894
+ const storeIds = new Set(allStores(catalog).map((store) => store.id));
1842
1895
 
1843
1896
  assertUniqueSlugs(
1844
1897
  catalog.contexts.map((c) => c.id),
@@ -2161,6 +2214,7 @@ export function validateCatalog(catalog: Catalog): Catalog {
2161
2214
 
2162
2215
  const steps = walkSteps(flow.steps);
2163
2216
  const stepIds = new Set<string>();
2217
+ const stepById = new Map<string, Step>();
2164
2218
  for (const step of steps) {
2165
2219
  if (stepIds.has(step.id)) {
2166
2220
  fail(
@@ -2169,6 +2223,16 @@ export function validateCatalog(catalog: Catalog): Catalog {
2169
2223
  );
2170
2224
  }
2171
2225
  stepIds.add(step.id);
2226
+ stepById.set(step.id, step);
2227
+
2228
+ if (
2229
+ !(["rpc", "event", "call", "response"] as const).includes(step.kind)
2230
+ ) {
2231
+ fail(
2232
+ `flow "${flow.slug}" step "${step.id}" has unknown kind "${step.kind}"`,
2233
+ `flow ${flow.id} / step ${step.id}`,
2234
+ );
2235
+ }
2172
2236
 
2173
2237
  if (step.reaches?.some((entrypoint) => entrypoint.length === 0)) {
2174
2238
  fail(
@@ -2208,6 +2272,55 @@ export function validateCatalog(catalog: Catalog): Catalog {
2208
2272
  );
2209
2273
  }
2210
2274
  }
2275
+ if (step.storeAccess) {
2276
+ if (step.kind !== "call") {
2277
+ fail(
2278
+ `flow "${flow.slug}" step "${step.id}" has store access metadata but is not a call`,
2279
+ `flow ${flow.id} / step ${step.id}`,
2280
+ );
2281
+ }
2282
+ if (!storeIds.has(step.storeAccess.store)) {
2283
+ fail(
2284
+ `flow "${flow.slug}" step "${step.id}" names unknown store "${step.storeAccess.store}"`,
2285
+ `flow ${flow.id} / step ${step.id}`,
2286
+ );
2287
+ }
2288
+ if (
2289
+ step.storeAccess.operation !== undefined &&
2290
+ !REDIS_OPERATIONS.includes(step.storeAccess.operation)
2291
+ ) {
2292
+ fail(
2293
+ `flow "${flow.slug}" step "${step.id}" has unknown store operation "${step.storeAccess.operation}"`,
2294
+ `flow ${flow.id} / step ${step.id}`,
2295
+ );
2296
+ }
2297
+ }
2298
+ if (step.http) {
2299
+ if (step.kind !== "response") {
2300
+ fail(
2301
+ `flow "${flow.slug}" step "${step.id}" has HTTP response metadata but is not a response`,
2302
+ `flow ${flow.id} / step ${step.id}`,
2303
+ );
2304
+ }
2305
+ if (
2306
+ step.http.status !== undefined &&
2307
+ (step.http.status < 100 || step.http.status > 599)
2308
+ ) {
2309
+ fail(
2310
+ `flow "${flow.slug}" response "${step.id}" has invalid HTTP status ${step.http.status}`,
2311
+ `flow ${flow.id} / step ${step.id}`,
2312
+ );
2313
+ }
2314
+ if (
2315
+ step.http.outcome !== undefined &&
2316
+ !(["success", "error"] as const).includes(step.http.outcome)
2317
+ ) {
2318
+ fail(
2319
+ `flow "${flow.slug}" response "${step.id}" has unknown HTTP outcome "${step.http.outcome}"`,
2320
+ `flow ${flow.id} / step ${step.id}`,
2321
+ );
2322
+ }
2323
+ }
2211
2324
 
2212
2325
  if (!lanes.has(step.from)) {
2213
2326
  fail(
@@ -2231,6 +2344,37 @@ export function validateCatalog(catalog: Catalog): Catalog {
2231
2344
  }
2232
2345
  }
2233
2346
  }
2347
+
2348
+ for (const step of steps) {
2349
+ if (step.kind !== "response") {
2350
+ if (step.replyTo !== undefined) {
2351
+ fail(
2352
+ `flow "${flow.slug}" step "${step.id}" is not a response but names replyTo "${step.replyTo}"`,
2353
+ `flow ${flow.id} / step ${step.id}`,
2354
+ );
2355
+ }
2356
+ continue;
2357
+ }
2358
+ if (!step.replyTo) {
2359
+ fail(
2360
+ `flow "${flow.slug}" response "${step.id}" names no request in replyTo`,
2361
+ `flow ${flow.id} / step ${step.id}`,
2362
+ );
2363
+ }
2364
+ const request = stepById.get(step.replyTo);
2365
+ if (!request || request.kind !== "rpc") {
2366
+ fail(
2367
+ `flow "${flow.slug}" response "${step.id}" replies to "${step.replyTo}", which is not an rpc request`,
2368
+ `flow ${flow.id} / step ${step.id}`,
2369
+ );
2370
+ }
2371
+ if (step.from !== request.to || step.to !== request.from) {
2372
+ fail(
2373
+ `flow "${flow.slug}" response "${step.id}" does not reverse request "${request.id}"`,
2374
+ `flow ${flow.id} / step ${step.id}`,
2375
+ );
2376
+ }
2377
+ }
2234
2378
  }
2235
2379
 
2236
2380
  validateExternals(catalog);
@@ -2511,7 +2655,10 @@ function validateStores(catalog: Catalog): void {
2511
2655
  }
2512
2656
  keyPatterns.add(keyspace.pattern);
2513
2657
  if (keyspace.operations.length === 0) {
2514
- fail(`Redis key pattern "${keyspace.pattern}" has no operations`, where);
2658
+ fail(
2659
+ `Redis key pattern "${keyspace.pattern}" has no operations`,
2660
+ where,
2661
+ );
2515
2662
  }
2516
2663
  const operations = new Set<string>();
2517
2664
  for (const operation of keyspace.operations) {
@@ -2529,6 +2676,41 @@ function validateStores(catalog: Catalog): void {
2529
2676
  }
2530
2677
  operations.add(operation);
2531
2678
  }
2679
+ const aggregateId = keyspace.persists?.aggregate;
2680
+ if (aggregateId && !aggregates.has(aggregateId)) {
2681
+ fail(
2682
+ `Redis key pattern "${keyspace.pattern}" persists unknown aggregate "${aggregateId}"`,
2683
+ where,
2684
+ );
2685
+ }
2686
+ const blockId = keyspace.persists?.block;
2687
+ if (blockId) {
2688
+ const blockAggregate =
2689
+ aggregates.get(blockId.split(".").slice(0, -1).join("."));
2690
+ const belongs = blockAggregate
2691
+ ? aggregateBlocks(blockAggregate).some(({ block }) => block.id === blockId)
2692
+ : false;
2693
+ if (!belongs) {
2694
+ fail(
2695
+ `Redis key pattern "${keyspace.pattern}" persists unknown block "${blockId}"`,
2696
+ where,
2697
+ );
2698
+ }
2699
+ }
2700
+ for (const access of keyspace.accesses ?? []) {
2701
+ if (!REDIS_OPERATIONS.includes(access.operation)) {
2702
+ fail(
2703
+ `Redis key pattern "${keyspace.pattern}" has access operation "${access.operation}"; expected one of ${REDIS_OPERATIONS.join(", ")}`,
2704
+ where,
2705
+ );
2706
+ }
2707
+ if (!operations.has(access.operation)) {
2708
+ fail(
2709
+ `Redis key pattern "${keyspace.pattern}" has ${access.operation} access absent from its operations summary`,
2710
+ where,
2711
+ );
2712
+ }
2713
+ }
2532
2714
  }
2533
2715
 
2534
2716
  for (const table of store.tables) {
@@ -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}