@shortlink-org/portolan 0.2.1 → 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 (107) 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 +363 -28
  34. package/scripts/local-api.test.mjs +195 -1
  35. package/scripts/plugin-host.mjs +14 -3
  36. package/scripts/plugin-host.test.mjs +8 -0
  37. package/scripts/schema.mjs +7 -0
  38. package/src/app/CatalogApp.tsx +19 -4
  39. package/src/app/CommandPalette.tsx +7 -2
  40. package/src/app/toast.tsx +11 -7
  41. package/src/catalog.test.ts +33 -0
  42. package/src/catalog.ts +190 -8
  43. package/src/components/CatIllustration.tsx +30 -0
  44. package/src/components/CommitLink.tsx +154 -0
  45. package/src/components/EditorLink.tsx +15 -8
  46. package/src/components/GrpcMethodReference.tsx +93 -0
  47. package/src/components/Markdown.tsx +17 -0
  48. package/src/components/MethodRows.tsx +8 -1
  49. package/src/components/PageHeader.test.ts +24 -0
  50. package/src/components/PageHeader.tsx +34 -0
  51. package/src/components/ShapeRows.tsx +12 -0
  52. package/src/components/SourceDoc.tsx +27 -12
  53. package/src/components/SourcePreview.tsx +124 -36
  54. package/src/enrich.test.ts +209 -2
  55. package/src/enrich.ts +284 -15
  56. package/src/er/RedisSchema.tsx +86 -5
  57. package/src/flow/FlowTable.tsx +19 -4
  58. package/src/flow/StepDetail.tsx +461 -101
  59. package/src/flow/StepRail.tsx +36 -13
  60. package/src/flow/answers-response.test.ts +100 -0
  61. package/src/flow/answers.ts +11 -8
  62. package/src/flow/chapters.ts +2 -1
  63. package/src/flow/labels.ts +10 -0
  64. package/src/flow/mermaid.test.ts +87 -3
  65. package/src/flow/mermaid.ts +26 -14
  66. package/src/index.css +132 -0
  67. package/src/landing/LandingPage.tsx +12 -8
  68. package/src/lib/api.test.ts +4 -6
  69. package/src/lib/build-info.test.ts +29 -0
  70. package/src/lib/build-info.ts +21 -0
  71. package/src/lib/data-model.ts +14 -0
  72. package/src/lib/flow-tree.ts +1 -1
  73. package/src/lib/github-catalog.test.ts +51 -0
  74. package/src/lib/github-catalog.ts +69 -0
  75. package/src/lib/grpc-reference.test.ts +72 -0
  76. package/src/lib/grpc-reference.ts +117 -0
  77. package/src/lib/local-api.ts +17 -4
  78. package/src/lib/product.ts +7 -0
  79. package/src/lib/queries.ts +16 -1
  80. package/src/lib/readme-assets.test.ts +19 -0
  81. package/src/lib/readme-assets.ts +34 -0
  82. package/src/lib/setup-info.test.ts +7 -0
  83. package/src/lib/setup-info.ts +5 -0
  84. package/src/lib/source-link.test.ts +106 -18
  85. package/src/lib/source-link.ts +22 -6
  86. package/src/merge.test.ts +55 -0
  87. package/src/merge.ts +30 -0
  88. package/src/pages/AdrDetail.tsx +3 -2
  89. package/src/pages/AdrIndex.tsx +10 -7
  90. package/src/pages/AggregatePage.tsx +34 -4
  91. package/src/pages/BlockPage.tsx +39 -32
  92. package/src/pages/Changes.tsx +7 -9
  93. package/src/pages/ContextMap.tsx +11 -2
  94. package/src/pages/ContextPage.tsx +11 -4
  95. package/src/pages/FlowIndex.tsx +16 -7
  96. package/src/pages/GraphPage.tsx +21 -7
  97. package/src/pages/Language.tsx +9 -9
  98. package/src/pages/NotFound.tsx +58 -15
  99. package/src/pages/Overview.tsx +6 -0
  100. package/src/pages/Problems.tsx +10 -8
  101. package/src/pages/RegistryIndex.tsx +10 -8
  102. package/src/pages/ServicePage.tsx +1 -1
  103. package/src/pages/Settings.tsx +382 -44
  104. package/src/routes.test.ts +2 -0
  105. package/src/routes.ts +2 -1
  106. package/src/selection/DetailPanel.tsx +213 -113
  107. package/src/testing/setup.ts +14 -0
@@ -23,7 +23,10 @@ describe("splitLine", () => {
23
23
  });
24
24
  });
25
25
  it("leaves anything else whole", () => {
26
- expect(splitLine("trace 9f2c1a../span 04")).toEqual({ path: "trace 9f2c1a../span 04", line: null });
26
+ expect(splitLine("trace 9f2c1a../span 04")).toEqual({
27
+ path: "trace 9f2c1a../span 04",
28
+ line: null,
29
+ });
27
30
  expect(splitLine("services/oms/test/e2e/checkout_test.go").line).toBeNull();
28
31
  });
29
32
  });
@@ -31,7 +34,10 @@ describe("splitLine", () => {
31
34
  const shop = { repo: "github.com/acme/shop" };
32
35
 
33
36
  const PINS: RepoPin[] = [
34
- { repo: "github.com/acme/shop", commit: "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0" },
37
+ {
38
+ repo: "github.com/acme/shop",
39
+ commit: "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0",
40
+ },
35
41
  ];
36
42
 
37
43
  describe("sourceHref", () => {
@@ -41,18 +47,37 @@ describe("sourceHref", () => {
41
47
  expect(sourceHref("examples/auth/internal/x.go:20", auth, [], GH)).toBe(
42
48
  "https://github.com/shortlink-org/portolan/blob/4f1c9ae0c1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6/examples/auth/internal/x.go#L20",
43
49
  );
44
- expect(sourceHref("examples/auth/README.md", auth, [], GH)).toMatch(/README\.md$/);
50
+ expect(sourceHref("examples/auth/README.md", auth, [], GH)).toMatch(
51
+ /README\.md$/,
52
+ );
53
+ });
54
+
55
+ it("recognizes the checkout's short name as the repository being built", () => {
56
+ expect(
57
+ sourceHref("internal/book/store.go:59", { repo: "portolan" }, [], GH),
58
+ ).toBe(
59
+ `https://github.com/shortlink-org/portolan/blob/${GH.commit}/internal/book/store.go#L59`,
60
+ );
45
61
  });
46
62
 
47
63
  it("uses GitLab's /-/blob/ when that is the forge", () => {
48
64
  const gl = { ...GH, repoUrl: "https://gitlab.com/acme/portolan" };
49
- expect(sourceHref("a/b.go:1", { repo: "gitlab.com/acme/portolan" }, [], gl)).toBe(
65
+ expect(
66
+ sourceHref("a/b.go:1", { repo: "gitlab.com/acme/portolan" }, [], gl),
67
+ ).toBe(
50
68
  "https://gitlab.com/acme/portolan/-/blob/4f1c9ae0c1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6/a/b.go#L1",
51
69
  );
52
70
  });
53
71
 
54
72
  it("does not link a service that lives in another repository, unpinned", () => {
55
- expect(sourceHref("internal/oms/app/checkout.go:104", { repo: "github.com/acme/shop" }, [], GH)).toBeNull();
73
+ expect(
74
+ sourceHref(
75
+ "internal/oms/app/checkout.go:104",
76
+ { repo: "github.com/acme/shop" },
77
+ [],
78
+ GH,
79
+ ),
80
+ ).toBeNull();
56
81
  });
57
82
 
58
83
  it("links a service in another repository at the commit it was fetched at", () => {
@@ -62,29 +87,54 @@ describe("sourceHref", () => {
62
87
  });
63
88
 
64
89
  it("removes the local vendoring prefix from an external source", () => {
65
- expect(sourceHref("vendor/repos/acme/shop/services/oms/app/checkout.go:104", shop, PINS, GH)).toBe(
90
+ expect(
91
+ sourceHref(
92
+ "vendor/repos/acme/shop/services/oms/app/checkout.go:104",
93
+ shop,
94
+ PINS,
95
+ GH,
96
+ ),
97
+ ).toBe(
66
98
  "https://github.com/acme/shop/blob/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0/services/oms/app/checkout.go#L104",
67
99
  );
68
100
  });
69
101
 
70
102
  it("reads a pin however the repository was spelled", () => {
71
- const pins: RepoPin[] = [{ repo: "https://GitHub.com/acme/shop.git", commit: "abc1234" }];
103
+ const pins: RepoPin[] = [
104
+ { repo: "https://GitHub.com/acme/shop.git", commit: "abc1234" },
105
+ ];
72
106
  expect(sourceHref("services/oms/app/checkout.go", shop, pins, GH)).toBe(
73
107
  "https://github.com/acme/shop/blob/abc1234/services/oms/app/checkout.go",
74
108
  );
75
109
  });
76
110
 
77
111
  it("prefers the build over a pin naming the repository this was built from", () => {
78
- const pins: RepoPin[] = [{ repo: "github.com/shortlink-org/portolan", commit: "0000000" }];
79
- expect(sourceHref("examples/auth/internal/x.go:20", auth, pins, GH)).toContain(GH.commit);
112
+ const pins: RepoPin[] = [
113
+ { repo: "github.com/shortlink-org/portolan", commit: "0000000" },
114
+ ];
115
+ expect(
116
+ sourceHref("examples/auth/internal/x.go:20", auth, pins, GH),
117
+ ).toContain(GH.commit);
80
118
  });
81
119
 
82
120
  it("does not link a pin with no commit", () => {
83
- expect(sourceHref("services/oms/app/checkout.go", shop, [{ repo: "github.com/acme/shop", commit: "" }], GH)).toBeNull();
121
+ expect(
122
+ sourceHref(
123
+ "services/oms/app/checkout.go",
124
+ shop,
125
+ [{ repo: "github.com/acme/shop", commit: "" }],
126
+ GH,
127
+ ),
128
+ ).toBeNull();
84
129
  });
85
130
 
86
131
  it("does not link when the build does not know its repository", () => {
87
- expect(sourceHref("examples/auth/internal/x.go:20", auth, [], { ...GH, repoUrl: "" })).toBeNull();
132
+ expect(
133
+ sourceHref("examples/auth/internal/x.go:20", auth, [], {
134
+ ...GH,
135
+ repoUrl: "",
136
+ }),
137
+ ).toBeNull();
88
138
  });
89
139
 
90
140
  it("does not link a place that is not a path", () => {
@@ -92,13 +142,22 @@ describe("sourceHref", () => {
92
142
  });
93
143
 
94
144
  it("links a service that names no repository, since there is only the one", () => {
95
- expect(sourceHref("docs/x.md", { repo: "" }, [], GH)).toMatch(/\/docs\/x\.md$/);
145
+ expect(sourceHref("docs/x.md", { repo: "" }, [], GH)).toMatch(
146
+ /\/docs\/x\.md$/,
147
+ );
96
148
  });
97
149
  });
98
150
 
99
151
  describe("sourceLocation", () => {
100
152
  it("keeps the immutable repository, commit, path, and line needed by a preview", () => {
101
- expect(sourceLocation("examples/auth/internal/x.go:20", { repo: "github.com/shortlink-org/portolan" }, [], GH)).toEqual({
153
+ expect(
154
+ sourceLocation(
155
+ "examples/auth/internal/x.go:20",
156
+ { repo: "github.com/shortlink-org/portolan" },
157
+ [],
158
+ GH,
159
+ ),
160
+ ).toEqual({
102
161
  kind: "remote",
103
162
  provider: "github",
104
163
  origin: "https://github.com",
@@ -111,8 +170,19 @@ describe("sourceLocation", () => {
111
170
  });
112
171
 
113
172
  it("uses the explicit forge hint for a self-hosted GitLab repository", () => {
114
- const gl = { ...GH, repoUrl: "https://git.example.test/acme/portolan", forge: "gitlab" as const };
115
- expect(sourceLocation("internal/x.go:3", { repo: "git.example.test/acme/portolan" }, [], gl)).toMatchObject({
173
+ const gl = {
174
+ ...GH,
175
+ repoUrl: "https://git.example.test/acme/portolan",
176
+ forge: "gitlab" as const,
177
+ };
178
+ expect(
179
+ sourceLocation(
180
+ "internal/x.go:3",
181
+ { repo: "git.example.test/acme/portolan" },
182
+ [],
183
+ gl,
184
+ ),
185
+ ).toMatchObject({
116
186
  kind: "remote",
117
187
  provider: "gitlab",
118
188
  origin: "https://git.example.test",
@@ -123,7 +193,13 @@ describe("sourceLocation", () => {
123
193
  });
124
194
 
125
195
  it("falls back to the localhost source service when no forge is known", () => {
126
- expect(sourceLocation("internal/x.go:3", null, [], { ...GH, repoUrl: "", commit: "" })).toEqual({
196
+ expect(
197
+ sourceLocation("internal/x.go:3", null, [], {
198
+ ...GH,
199
+ repoUrl: "",
200
+ commit: "",
201
+ }),
202
+ ).toEqual({
127
203
  kind: "local",
128
204
  path: "internal/x.go",
129
205
  line: 3,
@@ -134,7 +210,14 @@ describe("sourceLocation", () => {
134
210
 
135
211
  describe("treeHref", () => {
136
212
  it("opens the service's directory", () => {
137
- expect(treeHref("examples/auth", { repo: "github.com/shortlink-org/portolan" }, [], GH)).toBe(
213
+ expect(
214
+ treeHref(
215
+ "examples/auth",
216
+ { repo: "github.com/shortlink-org/portolan" },
217
+ [],
218
+ GH,
219
+ ),
220
+ ).toBe(
138
221
  "https://github.com/shortlink-org/portolan/tree/4f1c9ae0c1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6/examples/auth",
139
222
  );
140
223
  });
@@ -146,8 +229,13 @@ describe("treeHref", () => {
146
229
  });
147
230
 
148
231
  it("removes the local vendoring prefix from an external tree", () => {
149
- expect(treeHref("vendor/repos/acme/shop/services/oms", shop, PINS, GH)).toBe(
232
+ expect(
233
+ treeHref("vendor/repos/acme/shop/services/oms", shop, PINS, GH),
234
+ ).toBe(
150
235
  "https://github.com/acme/shop/tree/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0/services/oms",
151
236
  );
237
+ expect(treeHref("vendor/repos/acme/shop", shop, PINS, GH)).toBe(
238
+ "https://github.com/acme/shop/tree/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0",
239
+ );
152
240
  });
153
241
  });
@@ -23,7 +23,10 @@ import { buildInfo } from "./build-info";
23
23
  import type { BuildInfo } from "./build-info";
24
24
 
25
25
  /** A `file:line` split, or the whole thing as a path when it is not one. */
26
- export function splitLine(where: string): { path: string; line: number | null } {
26
+ export function splitLine(where: string): {
27
+ path: string;
28
+ line: number | null;
29
+ } {
27
30
  const m = /^(\S+?):(\d+)$/.exec(where);
28
31
  if (m) return { path: m[1]!, line: Number(m[2]) };
29
32
  return { path: where, line: null };
@@ -52,8 +55,13 @@ export function looksLikePath(path: string): boolean {
52
55
  function sameRepo(repo: string, info: BuildInfo): boolean {
53
56
  if (!info.repoUrl) return false;
54
57
  if (!repo) return true;
55
-
56
- return bare(info.repoUrl) === bare(repo);
58
+ const built = bare(info.repoUrl);
59
+ const declared = bare(repo);
60
+ if (built === declared) return true;
61
+ // Project extractors sometimes only know the checkout's directory name.
62
+ // It still identifies this build when it is a single segment matching the
63
+ // repository name at the end of the forge URL.
64
+ return !declared.includes("/") && built.split("/").at(-1) === declared;
57
65
  }
58
66
 
59
67
  /** A repository as `host/owner/name`, however it was spelled. */
@@ -70,7 +78,9 @@ function repositoryPath(path: string, repo: string, info: BuildInfo): string {
70
78
  if (!repo || sameRepo(repo, info)) return path;
71
79
  const segments = bare(repo).split("/").filter(Boolean);
72
80
  if (segments.length < 3) return path;
73
- const prefix = `vendor/repos/${segments.at(-2)}/${segments.at(-1)}/`;
81
+ const root = `vendor/repos/${segments.at(-2)}/${segments.at(-1)}`;
82
+ if (path === root) return "";
83
+ const prefix = `${root}/`;
74
84
  return path.startsWith(prefix) ? path.slice(prefix.length) : path;
75
85
  }
76
86
 
@@ -133,7 +143,8 @@ function providerFor(url: string, info: BuildInfo): "github" | "gitlab" | null {
133
143
  } catch {
134
144
  return null;
135
145
  }
136
- if (info.repoUrl && bare(info.repoUrl) === bare(url) && info.forge) return info.forge;
146
+ if (info.repoUrl && bare(info.repoUrl) === bare(url) && info.forge)
147
+ return info.forge;
137
148
  if (host === "github.com" || host.endsWith(".github.com")) return "github";
138
149
  if (host === "gitlab.com" || host.includes("gitlab")) return "gitlab";
139
150
  return null;
@@ -197,5 +208,10 @@ export function treeHref(
197
208
  const at = whereFor(service?.repo ?? "", pins, info);
198
209
  if (!at) return null;
199
210
 
200
- return `${at.url}${blobPath(at.url).replace("blob", "tree")}${at.ref}/${repositoryPath(path.replace(/\/$/, ""), service?.repo ?? "", info)}`;
211
+ const remotePath = repositoryPath(
212
+ path.replace(/\/$/, ""),
213
+ service?.repo ?? "",
214
+ info,
215
+ );
216
+ return `${at.url}${blobPath(at.url).replace("blob", "tree")}${at.ref}${remotePath ? `/${remotePath}` : ""}`;
201
217
  }
package/src/merge.test.ts CHANGED
@@ -1036,3 +1036,58 @@ describe("mergeCatalogs: maps", () => {
1036
1036
  );
1037
1037
  });
1038
1038
  });
1039
+
1040
+ describe("mergeCatalogs: Redis persistence", () => {
1041
+ it("links a Redis value type to the one matching owned aggregate", () => {
1042
+ const books = context("library", ["library.book"]);
1043
+ books.services[0]!.aggregates = [
1044
+ {
1045
+ id: "library.book.book",
1046
+ slug: "book",
1047
+ name: "Book",
1048
+ readme: "",
1049
+ root: "Book",
1050
+ entities: [
1051
+ {
1052
+ id: "library.book.book.book",
1053
+ slug: "book",
1054
+ name: "Book",
1055
+ doc: "",
1056
+ fields: [],
1057
+ },
1058
+ ],
1059
+ valueObjects: [],
1060
+ operations: [],
1061
+ events: [],
1062
+ },
1063
+ ];
1064
+
1065
+ const merged = mergeCatalogs([
1066
+ source("domain.json", { contexts: [books] }),
1067
+ source("redis.json", {
1068
+ contexts: [context("library", ["library.book"])],
1069
+ stores: [
1070
+ {
1071
+ id: "library.book.redis",
1072
+ slug: "redis",
1073
+ name: "Book Redis",
1074
+ kind: "redis",
1075
+ owner: "library.book",
1076
+ tables: [],
1077
+ keyspaces: [
1078
+ {
1079
+ pattern: "{id}",
1080
+ operations: ["read"],
1081
+ value: "domain.Book",
1082
+ },
1083
+ ],
1084
+ },
1085
+ ],
1086
+ }),
1087
+ ]);
1088
+
1089
+ expect(merged.catalog.stores?.[0]?.keyspaces?.[0]?.persists).toEqual({
1090
+ aggregate: "library.book.book",
1091
+ });
1092
+ });
1093
+ });
package/src/merge.ts CHANGED
@@ -403,8 +403,38 @@ function foldMaps(catalog: Catalog, stores: Store[]): Store[] {
403
403
  return spelled.join(".");
404
404
  };
405
405
 
406
+ const aggregateForValue = (
407
+ store: Store,
408
+ value: string | undefined,
409
+ ): Aggregate | undefined => {
410
+ const name = value?.match(/[A-Za-z_][A-Za-z0-9_]*$/)?.[0];
411
+ if (!name) return undefined;
412
+ const matches = [...aggregates.values()].filter(
413
+ (aggregate) =>
414
+ aggregate.id.startsWith(`${store.owner}.`) &&
415
+ (aggregate.root === name ||
416
+ aggregate.entities.some((entity) => entity.name === name)),
417
+ );
418
+ return matches.length === 1 ? matches[0] : undefined;
419
+ };
420
+
406
421
  return stores.map((store) => ({
407
422
  ...store,
423
+ ...(store.keyspaces
424
+ ? {
425
+ keyspaces: store.keyspaces.map((keyspace) => {
426
+ if (keyspace.persists?.aggregate || keyspace.persists?.block)
427
+ return keyspace;
428
+ const aggregate = aggregateForValue(store, keyspace.value);
429
+ return aggregate
430
+ ? {
431
+ ...keyspace,
432
+ persists: { aggregate: aggregate.id },
433
+ }
434
+ : keyspace;
435
+ }),
436
+ }
437
+ : {}),
408
438
  tables: store.tables.map((table) => {
409
439
  const aggregate = table.persists?.aggregate
410
440
  ? aggregates.get(table.persists.aggregate)
@@ -14,6 +14,7 @@ import {
14
14
  import { eventPath, paths, servicePath } from "../routes";
15
15
  import { PinButton } from "../app/pins";
16
16
  import { NotFound } from "./NotFound";
17
+ import { CommitLink } from "../components/CommitLink";
17
18
 
18
19
  /**
19
20
  * The body's own H1 repeats the title in the header of this page; MADR files
@@ -231,12 +232,12 @@ export function AdrDetail() {
231
232
  /** Who wrote the record down and when, from git: "committed by Ada Lovelace · 2026-01-01 · 0a1b2c3". */
232
233
  function CommitLine({ label, commit }: { label: string; commit: AdrCommit }) {
233
234
  return (
234
- <span title={`${label} in ${commit.commit}`}>
235
+ <span>
235
236
  {label} by <span className="text-ink">{commit.author || "unknown"}</span>
236
237
  {" · "}
237
238
  {commit.date.slice(0, 10)}
238
239
  {" · "}
239
- <Ident value={commit.commit}>{commit.commit.slice(0, 7)}</Ident>
240
+ <CommitLink commit={commit.commit} />
240
241
  </span>
241
242
  );
242
243
  }
@@ -1,6 +1,7 @@
1
1
  import { useDocumentTitle } from "../app/title";
2
2
  import { useMemo } from "react";
3
- import { CATALOG_PATH, catalog } from "../data";
3
+ import { Link } from "react-router";
4
+ import { catalog } from "../data";
4
5
  import type { Adr } from "../catalog";
5
6
  import { scopeLabel, sortAdrs } from "../lib/adr";
6
7
  import {
@@ -8,7 +9,7 @@ import {
8
9
  AdrScopePill,
9
10
  AdrStatusChip,
10
11
  } from "../components/primitives";
11
- import { Blank, Empty } from "../components/PageHeader";
12
+ import { CapabilityEmpty, Empty } from "../components/PageHeader";
12
13
  import { RowActions } from "../components/RowActions";
13
14
  import { DataTable } from "../table/DataTable";
14
15
  import type { ColumnSpec } from "../table/types";
@@ -85,11 +86,13 @@ export function AdrIndex() {
85
86
 
86
87
  {bare ? (
87
88
  <div className="mt-section">
88
- <Blank where={CATALOG_PATH}>
89
- Nothing on the record yet — an ADR is a decision as it was made,
90
- frozen. They are read from the repositories’ markdown and land in{" "}
91
- <span className="text-ink">adrs[]</span>.
92
- </Blank>
89
+ <CapabilityEmpty
90
+ title="No architecture decisions on the record yet"
91
+ signal="Signal: ADR markdown discovered by the project extractor adrs[]."
92
+ actions={<Link className="product-primary" to={catalog.contexts.length === 0 ? paths.settingsProjects() : paths.settingsPipeline()}>{catalog.contexts.length === 0 ? "Connect a project" : "Review extraction"}</Link>}
93
+ >
94
+ An ADR preserves a decision as it was made. Add or expose the repository’s decision markdown so Portolan can place it beside the affected architecture.
95
+ </CapabilityEmpty>
93
96
  </div>
94
97
  ) : (
95
98
  <div className="mt-section max-w-table">
@@ -12,7 +12,11 @@ import type {
12
12
  Enum,
13
13
  } from "../catalog";
14
14
  import { markdownOutline } from "../lib/derive";
15
- import { tablesPersisting, viewsPresenting } from "../lib/data-model";
15
+ import {
16
+ redisKeyspacesPersisting,
17
+ tablesPersisting,
18
+ viewsPresenting,
19
+ } from "../lib/data-model";
16
20
  import { plural } from "../lib/format";
17
21
  import { KIND_LABEL, KIND_PLURAL } from "../lib/kinds";
18
22
  import type { LeafKind } from "../lib/kinds";
@@ -352,6 +356,7 @@ export function AggregatePage() {
352
356
  const root = rootEntity(aggregate);
353
357
  const persistence = tablesPersisting(index, aggregate.id);
354
358
  const presented = viewsPresenting(index, aggregate.id);
359
+ const redisKeyspaces = redisKeyspacesPersisting(index, aggregate.id);
355
360
 
356
361
  // The readme's own headings first, then the five sections the page adds
357
362
  // under it. One rail, in the order the page is actually written in.
@@ -618,17 +623,17 @@ export function AggregatePage() {
618
623
  anchor={AGGREGATE_SECTION.persistence}
619
624
  right={
620
625
  <span>
621
- one row per table that holds it
626
+ tables and Redis keys that hold it
622
627
  {presented.length > 0 ? ", then the views over it" : ""}
623
628
  </span>
624
629
  }
625
630
  >
626
631
  Persistence
627
632
  </SectionTitle>
628
- {persistence.length === 0 ? (
633
+ {persistence.length === 0 && redisKeyspaces.length === 0 ? (
629
634
  <Empty>
630
635
  No persistence found for {aggregate.name} — the extractor maps
631
- tables via `persists` in migrations metadata
636
+ tables and Redis key families via `persists`
632
637
  </Empty>
633
638
  ) : (
634
639
  /* icon, name, what it is, store, width, actions. Tables and
@@ -673,6 +678,31 @@ export function AggregatePage() {
673
678
  </div>
674
679
  );
675
680
  })}
681
+ {redisKeyspaces.map(({ keyspace, store }) => (
682
+ <div
683
+ key={`${store.id}:${keyspace.pattern}`}
684
+ className="row px-2 py-1.5"
685
+ >
686
+ <KindIcon kind="store" />
687
+ <Link
688
+ to={paths.store(context.id, service.slug, store.slug)}
689
+ data-nav-item
690
+ className="mono min-w-0 break-all rounded-control"
691
+ >
692
+ {keyspace.pattern}
693
+ </Link>
694
+ <span className="chip">redis key</span>
695
+ <span className="mono text-muted">{store.slug}</span>
696
+ <span className="mono text-muted">
697
+ {keyspace.operations.join("/")}
698
+ </span>
699
+ <RowActions
700
+ copy={keyspace.pattern}
701
+ reveal={store.id}
702
+ label={keyspace.pattern}
703
+ />
704
+ </div>
705
+ ))}
676
706
  {/* A view is not persistence — it holds nothing — but it is
677
707
  how this aggregate is actually read back, and a reader who
678
708
  has found the tables wants the reports over them next. */}
@@ -218,7 +218,13 @@ export function BlockPage({ kind }: { kind: BlockKind }) {
218
218
  <PageHeader
219
219
  kind={
220
220
  <>
221
- {KIND_LABEL[kind]} ·{" "}
221
+ <span className="inline-flex items-center gap-1.5 font-semibold text-accent">
222
+ <KindIcon kind={kind} size={13} />
223
+ {KIND_LABEL[kind]}
224
+ </span>
225
+ <span aria-hidden className="mx-1.5 text-faint">
226
+ ·
227
+ </span>
222
228
  <Link
223
229
  to={paths.aggregate(context.id, service.slug, aggregate.slug)}
224
230
  className="rounded-control hover:text-ink hover:underline"
@@ -230,15 +236,18 @@ export function BlockPage({ kind }: { kind: BlockKind }) {
230
236
  name={block.name}
231
237
  id={block.id}
232
238
  right={
233
- <span className="flex items-center gap-2">
239
+ <span className="flex -translate-y-0.5 items-center gap-2">
234
240
  {isRoot ? (
235
- <span className="chip status-verified" title="aggregate root">
241
+ <span className="chip-lg status-verified" title="aggregate root">
236
242
  <span aria-hidden className="dot" />
237
243
  root
238
244
  </span>
239
245
  ) : null}
240
246
  {block.deprecated ? (
241
- <span className="chip" title="marked @deprecated in the source">
247
+ <span
248
+ className="chip-lg"
249
+ title="marked @deprecated in the source"
250
+ >
242
251
  deprecated
243
252
  </span>
244
253
  ) : null}
@@ -252,34 +261,32 @@ export function BlockPage({ kind }: { kind: BlockKind }) {
252
261
  </span>
253
262
  }
254
263
  >
255
- <p className="mt-2 max-w-prose text-muted">{block.doc}</p>
256
- <div className="mono mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-muted">
257
- {block.ref ? (
258
- <span className="flex items-center gap-1.5">
259
- shared type
260
- <Ident value={block.ref} className="text-ink" />— the same shape
261
- wherever it is named
262
- </span>
263
- ) : (
264
- <span>local to {aggregate.id} no shared type</span>
265
- )}
266
- <span aria-hidden className="h-4 w-px bg-line-strong" />
267
- <a
268
- href={`#${BLOCK_ANCHOR.shape}`}
269
- className="rounded-control hover:text-ink"
270
- >
271
- <span className="tnum">{fields.length}</span> fields
272
- </a>
273
- {block.ref ? (
274
- <a
275
- href={`#${LINKS_HERE}`}
276
- className="rounded-control hover:text-ink"
277
- >
278
- <span className="tnum">{backlinkCount(links)}</span>{" "}
279
- {plural(backlinkCount(links), "reference")}
280
- </a>
281
- ) : null}
282
- </div>
264
+ {block.doc ? (
265
+ <p className="mt-2 max-w-prose text-muted">{block.doc}</p>
266
+ ) : null}
267
+ {block.ref ? (
268
+ <dl className="mt-2.5 grid grid-cols-1 items-start gap-x-8 gap-y-2 sm:grid-cols-[minmax(0,max-content)_max-content] sm:justify-start">
269
+ <div className="min-w-0">
270
+ <dt className="label mb-1">Type scope</dt>
271
+ <dd className="mono flex min-w-0 flex-wrap items-center gap-1.5 text-muted">
272
+ <span>shared</span>
273
+ <Ident value={block.ref} className="text-ink" />
274
+ </dd>
275
+ </div>
276
+ <div>
277
+ <dt className="label mb-1">References</dt>
278
+ <dd>
279
+ <a
280
+ href={`#${LINKS_HERE}`}
281
+ className="mono rounded-control text-ink hover:text-accent hover:underline"
282
+ >
283
+ <span className="tnum">{backlinkCount(links)}</span>{" "}
284
+ {plural(backlinkCount(links), "reference")}
285
+ </a>
286
+ </dd>
287
+ </div>
288
+ </dl>
289
+ ) : null}
283
290
  </PageHeader>
284
291
 
285
292
  <div className="flex gap-section p-gutter">
@@ -22,6 +22,8 @@ import type { ForgeRepo } from "../lib/github-catalog";
22
22
  import { plural } from "../lib/format";
23
23
  import { comparisonQuery, comparisonSide, forgeKeys, forgeRefsQuery } from "../lib/queries";
24
24
  import { useForgeAccess } from "../app/forge-access";
25
+ import { CatEmptyState } from "../components/CatIllustration";
26
+ import { CommitLink } from "../components/CommitLink";
25
27
 
26
28
  const LABEL: Record<Severity, string> = {
27
29
  breaking: "Breaking",
@@ -35,10 +37,6 @@ const TONE: Record<Severity, string> = {
35
37
  change: "text-accent border-accent/30 bg-accent/5",
36
38
  };
37
39
 
38
- function short(sha: string): string {
39
- return sha.slice(0, 7) || "unknown";
40
- }
41
-
42
40
  function forgeName(repo: ForgeRepo): string {
43
41
  return repo.provider === "gitlab" ? "GitLab" : "GitHub";
44
42
  }
@@ -273,9 +271,9 @@ export function Changes() {
273
271
  </div>
274
272
 
275
273
  {loaded.changes.length === 0 ? (
276
- <div className="empty mt-4">
277
- No architectural change between these branch heads.
278
- </div>
274
+ <CatEmptyState scene="unchanged" title="No architectural changes" className="mt-4 max-w-prose">
275
+ These branch heads describe the same catalog.
276
+ </CatEmptyState>
279
277
  ) : shown.length === 0 ? (
280
278
  <div className="empty mt-4">No changes match these filters.</div>
281
279
  ) : (
@@ -301,8 +299,8 @@ export function Changes() {
301
299
  )}
302
300
 
303
301
  <div className="mono mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs text-muted">
304
- <span>{base} · {short(loaded.baseSha)}</span>
305
- <span>{head} · {short(loaded.headSha)}</span>
302
+ <span>{base} · <CommitLink commit={loaded.baseSha} repo={repo} /></span>
303
+ <span>{head} · <CommitLink commit={loaded.headSha} repo={repo} /></span>
306
304
  <span className="ml-auto">loaded from {repo?.provider === "gitlab" ? "GitLab" : "GitHub"} at runtime</span>
307
305
  </div>
308
306
  </>