@shortlink-org/portolan 0.3.0 → 0.4.0

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 (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -2,18 +2,25 @@ import { useDocumentTitle } from "../app/title";
2
2
  import { useState } from "react";
3
3
  import { Link } from "react-router";
4
4
  import { AlertTriangle } from "lucide-react";
5
- import { activeCatalogProfile, CATALOG_PATH, catalog, index } from "../data";
5
+ import { activeCatalogProfile, CATALOG_PATH, catalog } from "../data";
6
6
  import { contextOwners, contextStats, widestFlows } from "../lib/derive";
7
7
  import type { ContextOwner } from "../lib/derive";
8
- import { allProblems } from "../lib/all-problems";
8
+ import { bridges } from "../lib/centrality";
9
+ import { useProblems } from "../lib/use-problems";
9
10
  import { ctxStyle } from "../lib/context-color";
10
11
  import { middleTruncate, plural } from "../lib/format";
11
12
  import { useCountUp, staggerStyle } from "../lib/motion";
12
13
  import { usePhone } from "../app/responsive";
13
- import { CONTEXT_ANCHOR, OVERVIEW_ANCHOR, paths } from "../routes";
14
+ import { CONTEXT_ANCHOR, OVERVIEW_ANCHOR, paths, servicePath } from "../routes";
14
15
  import { Blank, SectionTitle } from "../components/PageHeader";
15
16
  import { C4View } from "../likec4/C4View";
16
- import { profileContainersViewId, profileLandscapeViewId } from "../likec4/ids";
17
+ import {
18
+ deploymentViewId,
19
+ environmentsOf,
20
+ profileContainersViewId,
21
+ profileLandscapeViewId,
22
+ } from "../likec4/ids";
23
+ import { servicesDeployedIn } from "../lib/environments";
17
24
  import { LevelSwitch } from "../likec4/levels";
18
25
  import type { C4Level } from "../likec4/levels";
19
26
  import { CatalogStamp } from "../components/CatalogStamp";
@@ -28,6 +35,9 @@ import {
28
35
  /** How many problems the overview lists before pointing at the page that lists them all. */
29
36
  const OVERVIEW_PROBLEMS = 4;
30
37
 
38
+ /** How many bridges the overview lists. Past the first few the shares are small and the list is the graph. */
39
+ const OVERVIEW_BRIDGES = 5;
40
+
31
41
  /**
32
42
  * A measurement that arrives rather than appears - 200ms, linear, once - and
33
43
  * then takes you to what it measured. A number that counts something the app
@@ -96,12 +106,23 @@ export function Overview() {
96
106
  useDocumentTitle("Overview");
97
107
  const phone = usePhone();
98
108
  const [level, setLevel] = useState<C4Level>(1);
109
+ // Where the estate runs, one environment at a time. Production first when
110
+ // there is one by that name: it is the environment a reader means by
111
+ // "deployed" until they say otherwise.
112
+ const environments = environmentsOf(catalog);
113
+ const [environment, setEnvironment] = useState<string>(
114
+ () =>
115
+ environments.find((e) => /^prod/i.test(e)) ?? environments[0] ?? "",
116
+ );
117
+ const deployedCount = (env: string) =>
118
+ servicesDeployedIn(catalog, new Set([env])).size;
99
119
  const reach = widestFlows(catalog);
120
+ const roads = bridges(catalog);
100
121
  const services = catalog.contexts.reduce(
101
122
  (count, context) => count + context.services.length,
102
123
  0,
103
124
  );
104
- const issues = allProblems(catalog, index);
125
+ const issues = useProblems();
105
126
  const issueCount = issues.length;
106
127
  const errorCount = issues.filter((p) => p.severity === "error").length;
107
128
 
@@ -189,6 +210,44 @@ export function Overview() {
189
210
  />
190
211
  </section>
191
212
 
213
+ {/* Where the estate runs, from the deployer's snapshot: the services
214
+ standing in one environment, in their clusters and namespaces, and
215
+ between them what the model declares. Absent entirely when no
216
+ snapshot places anything - a heading over an empty canvas would
217
+ claim the estate runs nowhere, and nobody made that claim. */}
218
+ {environments.length > 0 ? (
219
+ <section id={OVERVIEW_ANCHOR.deployed} className="mt-section">
220
+ <SectionTitle
221
+ anchor={OVERVIEW_ANCHOR.deployed}
222
+ right={
223
+ <div className="seg" role="group" aria-label="Environment">
224
+ {environments.map((env) => (
225
+ <button
226
+ key={env}
227
+ type="button"
228
+ onClick={() => setEnvironment(env)}
229
+ aria-pressed={env === environment}
230
+ className="flex items-center gap-1.5"
231
+ title={`${plural(deployedCount(env), "service")} of the estate in ${env}`}
232
+ >
233
+ {env}
234
+ <span className="tnum">{deployedCount(env)}</span>
235
+ </button>
236
+ ))}
237
+ </div>
238
+ }
239
+ >
240
+ Deployed
241
+ </SectionTitle>
242
+ <C4View
243
+ viewId={deploymentViewId(environment)}
244
+ height={phone ? 320 : 480}
245
+ controls={phone}
246
+ fitViewPadding={{ x: 8, y: 8 }}
247
+ />
248
+ </section>
249
+ ) : null}
250
+
192
251
  <section id={OVERVIEW_ANCHOR.contexts} className="mt-section">
193
252
  <SectionTitle anchor={OVERVIEW_ANCHOR.contexts}>Contexts</SectionTitle>
194
253
  <div
@@ -281,6 +340,85 @@ export function Overview() {
281
340
  </div>
282
341
  </section>
283
342
 
343
+ {/* The services the estate's paths run through: the share of shortest
344
+ paths between every other pair of services that pass through this
345
+ one, and the pairs of contexts it is the main road between. Degree
346
+ is not shown - a hub is visible on the graph, a bridge is not: the
347
+ service with three neighbours that is the only way from auth into
348
+ payments looks like nothing there. Nothing is shown for an estate
349
+ with no such service; a heading over "none" is a dead end. */}
350
+ {roads.length > 0 ? (
351
+ <section id={OVERVIEW_ANCHOR.bridges} className="mt-section">
352
+ <SectionTitle
353
+ anchor={OVERVIEW_ANCHOR.bridges}
354
+ right={
355
+ <span className="flex items-center gap-2">
356
+ {roads.length > OVERVIEW_BRIDGES ? (
357
+ <span className="section-aside">
358
+ {OVERVIEW_BRIDGES} of {roads.length}
359
+ </span>
360
+ ) : null}
361
+ <Link
362
+ to={paths.graph()}
363
+ className="rounded-control px-1 text-accent hover:underline"
364
+ >
365
+ the graph →
366
+ </Link>
367
+ </span>
368
+ }
369
+ >
370
+ Bridges
371
+ </SectionTitle>
372
+ <div className="flex flex-col gap-2" data-nav-list>
373
+ {roads.slice(0, OVERVIEW_BRIDGES).map((road, i) => {
374
+ const to = servicePath(road.id);
375
+ const percent = Math.round(road.betweenness * 100);
376
+ return (
377
+ <Link
378
+ key={road.id}
379
+ to={to ?? paths.graph()}
380
+ data-nav-item
381
+ className="row stagger-in flex-wrap"
382
+ style={staggerStyle(i)}
383
+ title={`${percent}% of the shortest paths between other services run through ${road.id}`}
384
+ >
385
+ {/* The share, as a bar the width of the number, so the
386
+ list reads as a ranking before any number is read. */}
387
+ <span className="flex w-16 shrink-0 items-center gap-1.5">
388
+ <span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface">
389
+ <span
390
+ className="block h-full bg-accent"
391
+ style={{ width: `${Math.max(percent, 2)}%` }}
392
+ />
393
+ </span>
394
+ <span className="mono tnum w-8 text-right text-muted">
395
+ {percent}%
396
+ </span>
397
+ </span>
398
+ <span className="font-semibold" title={road.id}>
399
+ {road.id}
400
+ </span>
401
+ <span className="mono text-muted">between</span>
402
+ <div className="flex flex-wrap items-center gap-1">
403
+ {road.between.map((pair) => (
404
+ <span
405
+ key={`${pair.a}~${pair.b}`}
406
+ className="flex items-center gap-1"
407
+ title={`${Math.round(pair.share * 100)}% of the shortest paths from ${pair.a} to ${pair.b} run through ${road.id}`}
408
+ >
409
+ <ContextPill id={pair.a} />
410
+ <span className="text-muted">↔</span>
411
+ <ContextPill id={pair.b} />
412
+ </span>
413
+ ))}
414
+ </div>
415
+ </Link>
416
+ );
417
+ })}
418
+ </div>
419
+ </section>
420
+ ) : null}
421
+
284
422
  <section id={OVERVIEW_ANCHOR.flows} className="mt-section">
285
423
  <SectionTitle
286
424
  anchor={OVERVIEW_ANCHOR.flows}
@@ -381,13 +519,15 @@ export function Overview() {
381
519
  Problems
382
520
  </SectionTitle>
383
521
  <div className="flex max-w-table flex-col gap-2" data-nav-list>
384
- {issues.slice(0, OVERVIEW_PROBLEMS).map((problem, i) => (
385
- <ProblemRow
386
- key={`${problem.kind}:${problem.id}:${problem.peer}`}
387
- problem={problem}
388
- index={i}
389
- />
390
- ))}
522
+ <div className="divide-y divide-line overflow-hidden rounded-card border border-line shadow-xs">
523
+ {issues.slice(0, OVERVIEW_PROBLEMS).map((problem, i) => (
524
+ <ProblemRow
525
+ key={`${problem.rule}:${problem.id}:${problem.peer}`}
526
+ problem={problem}
527
+ index={i}
528
+ />
529
+ ))}
530
+ </div>
391
531
  {issueCount > OVERVIEW_PROBLEMS ? (
392
532
  <Link
393
533
  to={paths.problems()}
@@ -0,0 +1,190 @@
1
+ import { useDocumentTitle } from "../app/title";
2
+ import { useEffect, useState } from "react";
3
+ import { Link, useLocation } from "react-router";
4
+ import { ArrowUpRight, ShieldCheck, Terminal } from "lucide-react";
5
+ import { CatIllustration } from "../components/CatIllustration";
6
+ import { SectionTitle } from "../components/PageHeader";
7
+ import { PluginIcon } from "../components/PluginIcon";
8
+ import {
9
+ pluginsByCategory,
10
+ pluginIcon,
11
+ pluginLabel,
12
+ pluginSourceHref,
13
+ runtimeLabel,
14
+ } from "../lib/plugins";
15
+ import type { PluginEntry } from "../lib/plugins";
16
+ import { PRODUCT_REPOSITORY } from "../lib/product";
17
+ import { paths } from "../routes";
18
+
19
+ const PHASE_LABEL = { extract: "extract", verify: "verify", generate: "generate" } as const;
20
+
21
+ /** Every cat the site has drawn; any of them suits a page that is a list. */
22
+ const CAT_SCENES = ["about", "clear", "unchanged", "onboarding", "search", "trial"] as const;
23
+
24
+ /**
25
+ * Every plugin the package ships, grouped by what it reads or what it makes.
26
+ *
27
+ * Rendered from the index `npm run schema` writes, so this page says what the
28
+ * plugins say about themselves and nothing else: the summary each answers to
29
+ * `describe`, the category it sorts itself under, the options it can be told.
30
+ * The Settings page shows which of these a build ran; this one shows what a
31
+ * build could run.
32
+ */
33
+ export function PluginIndex() {
34
+ useDocumentTitle("Plugins");
35
+ const groups = pluginsByCategory();
36
+ const total = groups.reduce((n, group) => n + group.plugins.length, 0);
37
+ const { hash } = useLocation();
38
+ const target = hash.startsWith("#plugin-") ? hash.slice("#plugin-".length) : null;
39
+ // A different cat each visit, the same one for the visit: picked once, not
40
+ // on every render, or a hash change would swap it mid-scroll.
41
+ const [cat] = useState(() => CAT_SCENES[Math.floor(Math.random() * CAT_SCENES.length)] ?? "about");
42
+
43
+ // A link from Settings names one plugin. The card grid settles a frame after
44
+ // the route commits, so the scroll waits for it rather than landing where
45
+ // the card was in a layout that no longer exists.
46
+ useEffect(() => {
47
+ if (!hash) return;
48
+ const id = hash.slice(1);
49
+ const frame = requestAnimationFrame(() => {
50
+ document.getElementById(id)?.scrollIntoView({ block: "start" });
51
+ });
52
+ return () => cancelAnimationFrame(frame);
53
+ }, [hash]);
54
+
55
+ return (
56
+ <div className="h-full overflow-y-auto p-gutter">
57
+ {/* The intro is a paragraph wide; on a wide screen the rest of the row
58
+ was empty, and a cat is a better use of it than nothing. */}
59
+ <div className="grid gap-grid xl:grid-cols-[minmax(0,1fr)_auto] xl:items-center">
60
+ <div>
61
+ <div className="flex flex-wrap items-center gap-3">
62
+ <h1 className="text-lg font-semibold">Plugins</h1>
63
+ <span className="mono text-muted">{total} shipped</span>
64
+ </div>
65
+ <p className="mt-2 max-w-prose text-muted">
66
+ Every fact in the catalog was read by one of these. An extractor reads a
67
+ tree and answers with a fragment; a verifier checks the catalog against
68
+ evidence; a generator turns it into something else. Each describes
69
+ itself, and this page is that description. Which of them a build ran is
70
+ on the{" "}
71
+ <Link to={paths.settingsPipeline()} className="text-accent hover:underline">
72
+ pipeline settings
73
+ </Link>
74
+ ; how to write one is in the{" "}
75
+ <a
76
+ href={`${PRODUCT_REPOSITORY}/tree/main/plugins#readme`}
77
+ target="_blank"
78
+ rel="noreferrer"
79
+ className="text-accent hover:underline"
80
+ >
81
+ plugin protocol
82
+ </a>
83
+ .
84
+ </p>
85
+ </div>
86
+ <CatIllustration scene={cat} className="hidden xl:block h-[126px] w-[180px] justify-self-end" />
87
+ </div>
88
+
89
+ <nav aria-label="Categories" className="mt-4 flex flex-wrap gap-1.5">
90
+ {groups.map((group) => (
91
+ <a key={group.category} href={`#${group.category}`} className="chip inline-flex items-center gap-1.5 border-line-strong hover:border-accent hover:text-accent">
92
+ <PluginIcon icon={{ lucide: group.icon }} size={12} />
93
+ {group.title} <span className="text-muted/70">{group.plugins.length}</span>
94
+ </a>
95
+ ))}
96
+ </nav>
97
+
98
+ <div className="mt-section space-y-section">
99
+ {groups.map((group) => (
100
+ <section key={group.category} id={group.category} className="scroll-mt-4">
101
+ <SectionTitle anchor={group.category} right={<span className="mono text-muted">{group.plugins.length}</span>}>
102
+ <span className="inline-flex items-center gap-2">
103
+ <PluginIcon icon={{ lucide: group.icon }} className="text-muted" />
104
+ {group.title}
105
+ </span>
106
+ </SectionTitle>
107
+ <p className="mb-3 max-w-prose text-muted">{group.what}</p>
108
+ <div className="grid gap-grid grid-cols-[repeat(auto-fill,minmax(340px,1fr))]">
109
+ {group.plugins.map((entry) => (
110
+ <PluginCard key={entry.name} entry={entry} highlighted={entry.name === target} />
111
+ ))}
112
+ </div>
113
+ </section>
114
+ ))}
115
+ </div>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ function PluginCard({ entry, highlighted }: { entry: PluginEntry; highlighted: boolean }) {
121
+ const options = Object.entries(entry.options.properties ?? {});
122
+ const required = new Set(entry.options.required ?? []);
123
+ const RuntimeIcon = entry.runtime === "wasm" ? ShieldCheck : Terminal;
124
+ const runtimeTone = entry.runtime === "wasm" ? "text-verified" : "text-declared";
125
+
126
+ return (
127
+ <article
128
+ id={`plugin-${entry.name}`}
129
+ // The margin clears the section head, which stays stuck to the top
130
+ // while its cards scroll under it.
131
+ className={`card card-static flex scroll-mt-14 flex-col gap-3 ${highlighted ? "border-accent" : ""}`}
132
+ >
133
+ <header className="flex flex-wrap items-center gap-x-2 gap-y-1">
134
+ <PluginIcon icon={pluginIcon(entry.name, entry.category)} size={16} className="text-ink" />
135
+ <h3 className="font-semibold text-ink">{pluginLabel(entry.name)}</h3>
136
+ <span className="mono text-muted" title="the name a manifest step uses">{entry.name}</span>
137
+ {entry.plugin !== entry.name ? (
138
+ <span className="mono text-faint" title="the plugin's own name">{entry.plugin}</span>
139
+ ) : null}
140
+ </header>
141
+
142
+ <p className="text-muted">{entry.summary}</p>
143
+
144
+ <div className="flex flex-wrap items-center gap-1.5">
145
+ {entry.phases.map((phase) => (
146
+ <span key={phase} className="chip border-line-strong">{PHASE_LABEL[phase]}</span>
147
+ ))}
148
+ <span className={`chip inline-flex items-center gap-1 border-line-strong ${runtimeTone}`}>
149
+ <RuntimeIcon size={12} aria-hidden /> {runtimeLabel(entry)}
150
+ </span>
151
+ {entry.needs?.map((need) => (
152
+ <span key={need} className="chip border-line-strong" title="asked of the host beyond the tree">
153
+ needs {need}
154
+ </span>
155
+ ))}
156
+ </div>
157
+
158
+ {options.length > 0 ? (
159
+ <details className="group">
160
+ <summary className="mono cursor-pointer list-none text-muted hover:text-ink">
161
+ {options.length} option{options.length === 1 ? "" : "s"}
162
+ <span className="ml-1 inline-block transition-transform group-open:rotate-90">›</span>
163
+ </summary>
164
+ <dl className="mt-2 space-y-2 border-l border-line pl-3">
165
+ {options.map(([key, option]) => (
166
+ <div key={key}>
167
+ <dt className="mono text-ink">
168
+ {key}
169
+ {required.has(key) ? <span className="ml-1 text-faint" title="required">*</span> : null}
170
+ </dt>
171
+ {option.description ? <dd className="text-muted">{option.description}</dd> : null}
172
+ </div>
173
+ ))}
174
+ </dl>
175
+ </details>
176
+ ) : (
177
+ <p className="mono text-faint">takes no options</p>
178
+ )}
179
+
180
+ <a
181
+ href={pluginSourceHref(entry)}
182
+ target="_blank"
183
+ rel="noreferrer"
184
+ className="mono mt-auto inline-flex items-center gap-1 text-accent hover:underline"
185
+ >
186
+ {entry.source} <ArrowUpRight size={12} aria-hidden />
187
+ </a>
188
+ </article>
189
+ );
190
+ }