@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
@@ -0,0 +1,131 @@
1
+ // Where a service runs, one row per Application the deployer lists for it.
2
+ //
3
+ // The environment is the row's identity: it is what a reader scans for -
4
+ // "is it in prod yet" - and what the snapshot groups by. The revision is the
5
+ // answer to the next question, "which commit", and is a link to the commit
6
+ // on the forge when it is one; a chart version is not, and is shown as it
7
+ // is. The application link opens the deployer's own page, which is where
8
+ // health and sync live: they move without a commit and are not kept here.
9
+
10
+ import { ExternalLink } from "lucide-react";
11
+ import type { Deployment } from "../catalog";
12
+ import { deploymentBasis } from "../catalog";
13
+ import { driftLines } from "../lib/deployment-drift";
14
+ import { Ident } from "./Ident";
15
+ import { RowActions } from "./RowActions";
16
+
17
+ /** The commit on the forge, when the revision is one and the repository is known. */
18
+ function commitHref(deployment: Deployment): string | null {
19
+ if (!deployment.repo || !/^[0-9a-f]{40}$/.test(deployment.revision)) {
20
+ return null;
21
+ }
22
+ const repo = `https://${deployment.repo}`;
23
+ const view = repo.toLowerCase().includes("gitlab") ? "/-/commit/" : "/commit/";
24
+ return `${repo}${view}${deployment.revision}`;
25
+ }
26
+
27
+ /** A commit shortened the way `git log --oneline` shortens it; anything else whole. */
28
+ function shortRevision(revision: string): string {
29
+ return /^[0-9a-f]{40}$/.test(revision) ? revision.slice(0, 7) : revision;
30
+ }
31
+
32
+ function DeploymentRow({ deployment }: { deployment: Deployment }) {
33
+ const href = commitHref(deployment);
34
+ const where = [deployment.cluster, deployment.namespace]
35
+ .filter(Boolean)
36
+ .join(" / ");
37
+ return (
38
+ <div className="row items-start px-2 py-1.5">
39
+ <span className="chip" title="the environment the application is placed in">
40
+ {deployment.environment}
41
+ </span>
42
+ <span className="mono text-muted" title="cluster / namespace">
43
+ {where}
44
+ </span>
45
+ <div className="min-w-0">
46
+ <span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
47
+ {deployment.revision ? (
48
+ href ? (
49
+ <a
50
+ href={href}
51
+ target="_blank"
52
+ rel="noreferrer"
53
+ className="mono text-ink hover:underline"
54
+ title={`${deployment.revision} on the forge`}
55
+ >
56
+ {shortRevision(deployment.revision)}
57
+ </a>
58
+ ) : (
59
+ <Ident value={deployment.revision}>
60
+ {shortRevision(deployment.revision)}
61
+ </Ident>
62
+ )
63
+ ) : (
64
+ <span className="text-muted">not synced yet</span>
65
+ )}
66
+ {deployment.targetRevision ? (
67
+ <span className="text-muted" title="what the application tracks">
68
+ tracks {deployment.targetRevision}
69
+ </span>
70
+ ) : null}
71
+ {deployment.tool ? (
72
+ <span className="chip mono">{deployment.tool}</span>
73
+ ) : null}
74
+ {/* Who said so, when only the tree has: what should run, with
75
+ nothing yet saying it does. A row both spoke for says nothing
76
+ here - agreement is the ordinary case and needs no chip. */}
77
+ {deploymentBasis(deployment) === "manifest" ? (
78
+ <span
79
+ className="chip status-declared"
80
+ title="declared in the GitOps tree; the deployer has not been read for it"
81
+ >
82
+ declared
83
+ </span>
84
+ ) : null}
85
+ {/* The tree and the deployer disagree. The chip carries the
86
+ difference in words, so a reader does not have to open both. */}
87
+ {driftLines(deployment).length > 0 ? (
88
+ <span
89
+ className="chip status-unresolved"
90
+ title={driftLines(deployment).join("\n")}
91
+ >
92
+ drift
93
+ </span>
94
+ ) : null}
95
+ </span>
96
+ {deployment.images?.length ? (
97
+ <ul className="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5">
98
+ {deployment.images.map((image) => (
99
+ <li key={image}>
100
+ <Ident value={image} className="text-muted">
101
+ {image}
102
+ </Ident>
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ ) : null}
107
+ </div>
108
+ <a
109
+ href={deployment.url}
110
+ target="_blank"
111
+ rel="noreferrer"
112
+ className="inline-flex items-center gap-1 rounded-control text-accent hover:underline"
113
+ title={`${deployment.name} in Argo CD, where its health and sync state are`}
114
+ >
115
+ {deployment.name}
116
+ <ExternalLink size={12} aria-hidden />
117
+ </a>
118
+ <RowActions copy={deployment.name} label={deployment.name} />
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export function DeploymentRows({ deployments }: { deployments: Deployment[] }) {
124
+ return (
125
+ <div className="rows grid-cols-[auto_auto_1fr_auto_auto]">
126
+ {deployments.map((deployment) => (
127
+ <DeploymentRow key={deployment.id} deployment={deployment} />
128
+ ))}
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,27 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DocsLinksContent } from "./DocsLinks";
4
+
5
+ describe("DocsLinks", () => {
6
+ it("searches Confluence by name and opens the Notion workspace", () => {
7
+ const markup = renderToStaticMarkup(
8
+ <DocsLinksContent
9
+ name="shop.oms"
10
+ confluence="https://acme.atlassian.net/wiki/spaces/ARCH"
11
+ notion="https://www.notion.so/acme"
12
+ />,
13
+ );
14
+ expect(markup).toContain("view in Confluence");
15
+ expect(markup).toContain(
16
+ 'href="https://acme.atlassian.net/wiki/search?text=shop.oms&amp;spaces=ARCH"',
17
+ );
18
+ expect(markup).toContain("open Notion");
19
+ expect(markup).toContain('href="https://www.notion.so/acme"');
20
+ });
21
+
22
+ it("renders nothing before either integration is configured", () => {
23
+ expect(
24
+ renderToStaticMarkup(<DocsLinksContent name="shop.oms" confluence="" notion="" />),
25
+ ).toBe("");
26
+ });
27
+ });
@@ -0,0 +1,56 @@
1
+ // The hand-off from a page to where its team writes: a Confluence search by
2
+ // name, and the Notion workspace. Nothing until the reader has configured
3
+ // either under Settings → Integrations; the values live in this browser.
4
+
5
+ import { ExternalLink } from "lucide-react";
6
+ import { confluenceSearchUrl, useConfluence } from "../lib/confluence";
7
+ import { useNotion } from "../lib/notion";
8
+
9
+ const LINK =
10
+ "inline-flex items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline";
11
+
12
+ export function DocsLinksContent({
13
+ name,
14
+ confluence,
15
+ notion,
16
+ }: {
17
+ /** What to search for: the service's or context's name. */
18
+ name: string;
19
+ confluence: string;
20
+ notion: string;
21
+ }) {
22
+ const search = confluenceSearchUrl(confluence, name);
23
+ if (!search && !notion) return null;
24
+ return (
25
+ <>
26
+ {search ? (
27
+ <a
28
+ href={search}
29
+ target="_blank"
30
+ rel="noreferrer"
31
+ className={LINK}
32
+ title={`Search Confluence for ${name}`}
33
+ >
34
+ view in Confluence <ExternalLink size={12} aria-hidden />
35
+ </a>
36
+ ) : null}
37
+ {notion ? (
38
+ <a
39
+ href={notion}
40
+ target="_blank"
41
+ rel="noreferrer"
42
+ className={LINK}
43
+ title="Open the Notion workspace where the documentation lives"
44
+ >
45
+ open Notion <ExternalLink size={12} aria-hidden />
46
+ </a>
47
+ ) : null}
48
+ </>
49
+ );
50
+ }
51
+
52
+ export function DocsLinks({ name }: { name: string }) {
53
+ const confluence = useConfluence((state) => state.url);
54
+ const notion = useNotion((state) => state.url);
55
+ return <DocsLinksContent name={name} confluence={confluence} notion={notion} />;
56
+ }
@@ -22,6 +22,7 @@ import type { Scope, Shape } from "../lib/shape";
22
22
  import { blockPath, enumPath } from "../routes";
23
23
  import { Ident } from "./Ident";
24
24
  import { KindIcon } from "./kind";
25
+ import { RuleMarks } from "./RuleMarks";
25
26
 
26
27
  /** The words the type chip uses for what `parseType` found. */
27
28
  const CARDINALITY: Record<"many" | "map", string> = {
@@ -31,8 +32,9 @@ const CARDINALITY: Record<"many" | "map", string> = {
31
32
 
32
33
  /**
33
34
  * The type of a field, as a reader wants it: the raw spelling to copy, a
34
- * word for the wrapper it is in, and a way to the page of the shape it
35
- * names. Shared by the table cell and the tree row so both say it the same.
35
+ * word for the wrapper it is in, what the source says the value must
36
+ * satisfy, and a way to the page of the shape it names. Shared by the table
37
+ * cell and the tree row so both say it the same.
36
38
  */
37
39
  export function TypeCell({
38
40
  field,
@@ -70,6 +72,7 @@ export function TypeCell({
70
72
  optional
71
73
  </span>
72
74
  ) : null}
75
+ <RuleMarks field={field} />
73
76
  {page && shape ? (
74
77
  <Link
75
78
  to={page}
@@ -4,7 +4,7 @@ import type { HTTPDestination } from "../catalog";
4
4
  export function HTTPDestinationEvidence({ destination: d }: { destination: HTTPDestination }) {
5
5
  const basis = d.resolution?.basis;
6
6
  const rows = [
7
- ["Linked by", basis === "full-path" ? "Recovered full path" : basis === "exact-route" ? "Exact route" : basis === "unique-suffix" ? "Unique route suffix (heuristic)" : "Unresolved"],
7
+ ["Linked by", basis === "full-path" ? "Recovered full path" : basis === "exact-route" ? "Exact route" : basis === "unique-suffix" ? "Unique route suffix (heuristic)" : basis === "kubernetes-host" ? "Kubernetes Service host, from the manifests" : "Unresolved"],
8
8
  ["Call site", d.callSite],
9
9
  ["Endpoint expression", d.endpointExpression],
10
10
  ["Local path", d.localPath],
@@ -0,0 +1,77 @@
1
+ // The mark beside a plugin's name: the brand's own where the plugin reads one
2
+ // technology the reader knows by its logo, a lucide glyph where it reads a
3
+ // format or a convention that has none. Both are drawn at the size and weight
4
+ // of the lucide row they sit in, which is what TechIcon's opened viewBox is for.
5
+
6
+ import {
7
+ BookOpen,
8
+ Cloud,
9
+ CodeXml,
10
+ Database,
11
+ FileCode2,
12
+ FileSignature,
13
+ FolderGit2,
14
+ GitFork,
15
+ Globe,
16
+ Inbox,
17
+ MessageSquare,
18
+ Package,
19
+ PenLine,
20
+ Route,
21
+ Share2,
22
+ ShieldCheck,
23
+ SpellCheck,
24
+ Terminal,
25
+ Users,
26
+ Waves,
27
+ Workflow,
28
+ } from "lucide-react";
29
+ import type { LucideIcon } from "lucide-react";
30
+
31
+ import type { PluginGlyph, PluginIconSpec } from "../lib/plugins";
32
+ import { techGlyph } from "../lib/tech";
33
+ import { TechIcon } from "./TechIcon";
34
+
35
+ const GLYPHS: Record<PluginGlyph, LucideIcon> = {
36
+ code: CodeXml,
37
+ contract: FileSignature,
38
+ message: MessageSquare,
39
+ database: Database,
40
+ cloud: Cloud,
41
+ repository: FolderGit2,
42
+ pen: PenLine,
43
+ shield: ShieldCheck,
44
+ fork: GitFork,
45
+ share: Share2,
46
+ inbox: Inbox,
47
+ "file-code": FileCode2,
48
+ package: Package,
49
+ waves: Waves,
50
+ workflow: Workflow,
51
+ users: Users,
52
+ globe: Globe,
53
+ terminal: Terminal,
54
+ book: BookOpen,
55
+ "spell-check": SpellCheck,
56
+ route: Route,
57
+ };
58
+
59
+ export function PluginIcon({
60
+ icon,
61
+ size = 14,
62
+ className = "",
63
+ }: {
64
+ icon: PluginIconSpec;
65
+ size?: number;
66
+ className?: string;
67
+ }) {
68
+ if ("brand" in icon) {
69
+ const glyph = techGlyph(icon.brand);
70
+ // A brand the mark list does not carry is a mistake the test catches; at
71
+ // run time it falls back to the generic code glyph rather than to nothing.
72
+ if (glyph) return <TechIcon glyph={glyph} size={size} className={className} />;
73
+ return <CodeXml size={size} aria-hidden className={`block shrink-0 ${className}`} />;
74
+ }
75
+ const Glyph = GLYPHS[icon.lucide];
76
+ return <Glyph size={size} aria-hidden className={`block shrink-0 ${className}`} />;
77
+ }