@grimoire-rs/indexer 0.4.4 → 0.5.1

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 (71) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/NOTICE +30 -0
  3. package/README.md +76 -331
  4. package/dist/cli/dev.d.ts +14 -0
  5. package/dist/cli/dev.d.ts.map +1 -1
  6. package/dist/cli/dev.js +22 -1
  7. package/dist/cli/dev.js.map +1 -1
  8. package/dist/cli/init.d.ts.map +1 -1
  9. package/dist/cli/init.js +35 -4
  10. package/dist/cli/init.js.map +1 -1
  11. package/dist/cli/main.d.ts.map +1 -1
  12. package/dist/cli/main.js +5 -0
  13. package/dist/cli/main.js.map +1 -1
  14. package/dist/config.d.ts +107 -7
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +181 -36
  17. package/dist/config.js.map +1 -1
  18. package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
  19. package/dist/renderer/astro/components/CardLogo.js +58 -0
  20. package/dist/renderer/astro/components/CardLogo.tsx +96 -0
  21. package/dist/renderer/astro/components/Catalog.d.ts +14 -1
  22. package/dist/renderer/astro/components/Catalog.js +573 -108
  23. package/dist/renderer/astro/components/Catalog.tsx +903 -349
  24. package/dist/renderer/astro/components/CodeBlock.astro +74 -0
  25. package/dist/renderer/astro/components/CommandBar.astro +66 -0
  26. package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
  27. package/dist/renderer/astro/components/CopyButton.js +28 -0
  28. package/dist/renderer/astro/components/CopyButton.tsx +56 -0
  29. package/dist/renderer/astro/components/KindMark.d.ts +69 -0
  30. package/dist/renderer/astro/components/KindMark.js +66 -0
  31. package/dist/renderer/astro/components/KindMark.tsx +141 -0
  32. package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
  33. package/dist/renderer/astro/components/PackageCard.js +50 -0
  34. package/dist/renderer/astro/components/PackageCard.tsx +273 -0
  35. package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
  36. package/dist/renderer/astro/components/PackageRow.js +32 -0
  37. package/dist/renderer/astro/components/PackageRow.tsx +126 -0
  38. package/dist/renderer/astro/components/PickerMenu.astro +5 -14
  39. package/dist/renderer/astro/components/SiteFooter.astro +64 -0
  40. package/dist/renderer/astro/components/SiteHeader.astro +74 -0
  41. package/dist/renderer/astro/components/VersionMenu.astro +2 -2
  42. package/dist/renderer/astro/layouts/Base.astro +970 -227
  43. package/dist/renderer/astro/lib/base.d.ts +25 -0
  44. package/dist/renderer/astro/lib/base.js +23 -0
  45. package/dist/renderer/astro/lib/base.ts +27 -0
  46. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  47. package/dist/renderer/astro/lib/catalog.js +36 -0
  48. package/dist/renderer/astro/lib/catalog.ts +37 -0
  49. package/dist/renderer/astro/lib/code.d.ts +2 -2
  50. package/dist/renderer/astro/lib/code.js +2 -2
  51. package/dist/renderer/astro/lib/code.ts +2 -2
  52. package/dist/renderer/astro/lib/commands.d.ts +64 -0
  53. package/dist/renderer/astro/lib/commands.js +91 -0
  54. package/dist/renderer/astro/lib/commands.ts +133 -0
  55. package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
  56. package/dist/renderer/astro/lib/keywordRail.js +99 -0
  57. package/dist/renderer/astro/lib/keywordRail.ts +110 -0
  58. package/dist/renderer/astro/pages/index.astro +40 -87
  59. package/dist/renderer/astro/pages/p/[...slug].astro +349 -198
  60. package/dist/renderer/astro/styles/tokens.css +40 -5
  61. package/dist/renderer/index.d.ts +77 -0
  62. package/dist/renderer/index.d.ts.map +1 -1
  63. package/dist/renderer/index.js +565 -7
  64. package/dist/renderer/index.js.map +1 -1
  65. package/dist/renderer/types.d.ts +9 -0
  66. package/dist/renderer/types.d.ts.map +1 -1
  67. package/package.json +9 -4
  68. package/templates/README.md +6 -0
  69. package/templates/gitignore +4 -1
  70. package/templates/theme/README.md +38 -0
  71. package/templates/tsconfig.json +47 -0
@@ -7,4 +7,29 @@
7
7
  * fragment — already resolves and is returned untouched.
8
8
  */
9
9
  export declare function withBase(url: string): string;
10
+ /**
11
+ * The attributes a nav or footer link carries beyond its `href`.
12
+ *
13
+ * Two orthogonal axes, and conflating them is the defect this exists to
14
+ * stop. `withBase` applies to *every* link, unconditionally — it already
15
+ * no-ops on anything absolute — so guarding it on "is this internal" is
16
+ * never necessary and drops the base path from a site-root link the moment
17
+ * `external` forces one into a new tab. This decides the new-tab affordance
18
+ * and nothing else.
19
+ *
20
+ * `??`, not `||`: `external: false` is a decision — an absolute URL that is
21
+ * still this site, an intranet mirror or a staging host — and it is the one
22
+ * case the href's shape cannot express. Collapsing it into "unset" is what
23
+ * makes the field pointless.
24
+ *
25
+ * Shared by `SiteHeader` and `SiteFooter` rather than written twice: they
26
+ * shipped with two different answers to this question, which is the bug.
27
+ */
28
+ export declare function linkAttrs(link: {
29
+ href: string;
30
+ external?: boolean;
31
+ }): {
32
+ target?: string;
33
+ rel?: string;
34
+ };
10
35
  //# sourceMappingURL=base.d.ts.map
@@ -15,3 +15,26 @@ export function withBase(url) {
15
15
  // doubles the slash.
16
16
  return __GRIMOIRE_BASE__.replace(/\/$/, "") + url;
17
17
  }
18
+ /**
19
+ * The attributes a nav or footer link carries beyond its `href`.
20
+ *
21
+ * Two orthogonal axes, and conflating them is the defect this exists to
22
+ * stop. `withBase` applies to *every* link, unconditionally — it already
23
+ * no-ops on anything absolute — so guarding it on "is this internal" is
24
+ * never necessary and drops the base path from a site-root link the moment
25
+ * `external` forces one into a new tab. This decides the new-tab affordance
26
+ * and nothing else.
27
+ *
28
+ * `??`, not `||`: `external: false` is a decision — an absolute URL that is
29
+ * still this site, an intranet mirror or a staging host — and it is the one
30
+ * case the href's shape cannot express. Collapsing it into "unset" is what
31
+ * makes the field pointless.
32
+ *
33
+ * Shared by `SiteHeader` and `SiteFooter` rather than written twice: they
34
+ * shipped with two different answers to this question, which is the bug.
35
+ */
36
+ export function linkAttrs(link) {
37
+ return link.external ?? !link.href.startsWith("/")
38
+ ? { target: "_blank", rel: "noopener noreferrer" }
39
+ : {};
40
+ }
@@ -22,3 +22,30 @@ export function withBase(url: string): string {
22
22
  // doubles the slash.
23
23
  return __GRIMOIRE_BASE__.replace(/\/$/, "") + url;
24
24
  }
25
+
26
+ /**
27
+ * The attributes a nav or footer link carries beyond its `href`.
28
+ *
29
+ * Two orthogonal axes, and conflating them is the defect this exists to
30
+ * stop. `withBase` applies to *every* link, unconditionally — it already
31
+ * no-ops on anything absolute — so guarding it on "is this internal" is
32
+ * never necessary and drops the base path from a site-root link the moment
33
+ * `external` forces one into a new tab. This decides the new-tab affordance
34
+ * and nothing else.
35
+ *
36
+ * `??`, not `||`: `external: false` is a decision — an absolute URL that is
37
+ * still this site, an intranet mirror or a staging host — and it is the one
38
+ * case the href's shape cannot express. Collapsing it into "unset" is what
39
+ * makes the field pointless.
40
+ *
41
+ * Shared by `SiteHeader` and `SiteFooter` rather than written twice: they
42
+ * shipped with two different answers to this question, which is the bug.
43
+ */
44
+ export function linkAttrs(link: { href: string; external?: boolean }): {
45
+ target?: string;
46
+ rel?: string;
47
+ } {
48
+ return link.external ?? !link.href.startsWith("/")
49
+ ? { target: "_blank", rel: "noopener noreferrer" }
50
+ : {};
51
+ }
@@ -10,6 +10,20 @@ export type { CatalogPackage } from "../../types.js";
10
10
  * unknown bucket on the first build after an upgrade.
11
11
  */
12
12
  export declare function lastUpdated(p: CatalogPackage): string | undefined;
13
+ /**
14
+ * The deprecation sentence, or `null` for a package that is not deprecated.
15
+ *
16
+ * `deprecated` is the publisher's own **message**, not a date — grim's own
17
+ * detail pane renders it as `Deprecated: use acme/code-review-2`. Dropping it
18
+ * and printing the bare word left the reader with the one thing they already
19
+ * knew from the badge and none of the reason, so it is the message that leads
20
+ * here; `replacedBy` is a separate field and rides after it.
21
+ *
22
+ * The detail page states it; a card shows the `deprecated` badge and stops
23
+ * there. A retirement notice is usually a sentence, and a sentence on a card
24
+ * pushed the description out of a grid whose rows have to stay even.
25
+ */
26
+ export declare function deprecationNote(p: CatalogPackage): string | null;
13
27
  export declare function versionCascade(version: string | undefined, tags: string[]): string[];
14
28
  export declare function compareVersions(a: string, b: string): number;
15
29
  export declare function timeAgo(iso: string): string;
@@ -29,6 +43,16 @@ export declare function timeAgo(iso: string): string;
29
43
  export declare function externalUrl(raw: unknown): string | null;
30
44
  /** `vscode://<publisher.extension>/open?repo=<ref>`, or null when disabled. */
31
45
  export declare function vscodeUrl(extension: string | null, ref: string): string | null;
46
+ /**
47
+ * One-click upvote through the VS Code extension.
48
+ *
49
+ * `/vote?repo=` is a route the extension ships for exactly this — its own
50
+ * handler comment says it "lets an index page offer a one-click upvote". The
51
+ * link authorizes nothing on its own: the extension gates the public post
52
+ * behind a disclosure modal naming the forge and the thread, and it refuses
53
+ * to *retract* from a link at all, since a retraction waives that modal.
54
+ */
55
+ export declare function vscodeVoteUrl(extension: string | null, ref: string): string | null;
32
56
  /**
33
57
  * `vscode://<publisher.extension>/add-registry?index=<url>&alias=<name>` — the
34
58
  * one-click counterpart of the `grim config registry add` line beside it.
@@ -12,6 +12,30 @@
12
12
  export function lastUpdated(p) {
13
13
  return p.updated ?? p.created;
14
14
  }
15
+ /**
16
+ * The deprecation sentence, or `null` for a package that is not deprecated.
17
+ *
18
+ * `deprecated` is the publisher's own **message**, not a date — grim's own
19
+ * detail pane renders it as `Deprecated: use acme/code-review-2`. Dropping it
20
+ * and printing the bare word left the reader with the one thing they already
21
+ * knew from the badge and none of the reason, so it is the message that leads
22
+ * here; `replacedBy` is a separate field and rides after it.
23
+ *
24
+ * The detail page states it; a card shows the `deprecated` badge and stops
25
+ * there. A retirement notice is usually a sentence, and a sentence on a card
26
+ * pushed the description out of a grid whose rows have to stay even.
27
+ */
28
+ export function deprecationNote(p) {
29
+ if (!p.deprecated)
30
+ return null;
31
+ const reason = typeof p.deprecated === "string" ? p.deprecated.trim() : "";
32
+ return [
33
+ reason ? `deprecated: ${reason}` : "deprecated",
34
+ p.replacedBy ? `replaced by ${p.replacedBy}` : "",
35
+ ]
36
+ .filter(Boolean)
37
+ .join(" — ");
38
+ }
15
39
  // Publishing 0.10.0 also moves the rolling tags 0.10, 0 and latest, so the
16
40
  // full tag list is mostly history. Return just the current release's chain
17
41
  // (latest | 0 | 0.10 | 0.10.0). `tags` must already be sorted newest-first.
@@ -104,6 +128,18 @@ export function externalUrl(raw) {
104
128
  export function vscodeUrl(extension, ref) {
105
129
  return extension ? `vscode://${extension}/open?repo=${encodeURIComponent(ref)}` : null;
106
130
  }
131
+ /**
132
+ * One-click upvote through the VS Code extension.
133
+ *
134
+ * `/vote?repo=` is a route the extension ships for exactly this — its own
135
+ * handler comment says it "lets an index page offer a one-click upvote". The
136
+ * link authorizes nothing on its own: the extension gates the public post
137
+ * behind a disclosure modal naming the forge and the thread, and it refuses
138
+ * to *retract* from a link at all, since a retraction waives that modal.
139
+ */
140
+ export function vscodeVoteUrl(extension, ref) {
141
+ return extension ? `vscode://${extension}/vote?repo=${encodeURIComponent(ref)}` : null;
142
+ }
107
143
  /**
108
144
  * Alias charset the extension's `/add-registry` handler accepts. It has to be
109
145
  * a TOML bare key and safe as a CLI argument, so it refuses a link rather
@@ -22,6 +22,30 @@ export function lastUpdated(p: CatalogPackage): string | undefined {
22
22
  return p.updated ?? p.created;
23
23
  }
24
24
 
25
+ /**
26
+ * The deprecation sentence, or `null` for a package that is not deprecated.
27
+ *
28
+ * `deprecated` is the publisher's own **message**, not a date — grim's own
29
+ * detail pane renders it as `Deprecated: use acme/code-review-2`. Dropping it
30
+ * and printing the bare word left the reader with the one thing they already
31
+ * knew from the badge and none of the reason, so it is the message that leads
32
+ * here; `replacedBy` is a separate field and rides after it.
33
+ *
34
+ * The detail page states it; a card shows the `deprecated` badge and stops
35
+ * there. A retirement notice is usually a sentence, and a sentence on a card
36
+ * pushed the description out of a grid whose rows have to stay even.
37
+ */
38
+ export function deprecationNote(p: CatalogPackage): string | null {
39
+ if (!p.deprecated) return null;
40
+ const reason = typeof p.deprecated === "string" ? p.deprecated.trim() : "";
41
+ return [
42
+ reason ? `deprecated: ${reason}` : "deprecated",
43
+ p.replacedBy ? `replaced by ${p.replacedBy}` : "",
44
+ ]
45
+ .filter(Boolean)
46
+ .join(" — ");
47
+ }
48
+
25
49
  // Publishing 0.10.0 also moves the rolling tags 0.10, 0 and latest, so the
26
50
  // full tag list is mostly history. Return just the current release's chain
27
51
  // (latest | 0 | 0.10 | 0.10.0). `tags` must already be sorted newest-first.
@@ -117,6 +141,19 @@ export function vscodeUrl(extension: string | null, ref: string): string | null
117
141
  return extension ? `vscode://${extension}/open?repo=${encodeURIComponent(ref)}` : null;
118
142
  }
119
143
 
144
+ /**
145
+ * One-click upvote through the VS Code extension.
146
+ *
147
+ * `/vote?repo=` is a route the extension ships for exactly this — its own
148
+ * handler comment says it "lets an index page offer a one-click upvote". The
149
+ * link authorizes nothing on its own: the extension gates the public post
150
+ * behind a disclosure modal naming the forge and the thread, and it refuses
151
+ * to *retract* from a link at all, since a retraction waives that modal.
152
+ */
153
+ export function vscodeVoteUrl(extension: string | null, ref: string): string | null {
154
+ return extension ? `vscode://${extension}/vote?repo=${encodeURIComponent(ref)}` : null;
155
+ }
156
+
120
157
  /**
121
158
  * Alias charset the extension's `/add-registry` handler accepts. It has to be
122
159
  * a TOML bare key and safe as a CLI argument, so it refuses a link rather
@@ -4,8 +4,8 @@
4
4
  * Shiki is what Astro already ships — the same highlighter VS Code renders
5
5
  * with — so nothing here adds a library; this only stops the two consumers
6
6
  * drifting onto different themes. The markdown pipeline reads it through
7
- * `astro.config`'s `shikiConfig`, and the `<Code>` component on the package
8
- * page takes it as a prop.
7
+ * `astro.config`'s `shikiConfig`, and `CodeBlock.astro` what every
8
+ * hand-written snippet on the site goes through — passes it to `<Code>`.
9
9
  *
10
10
  * A *pair*, not one theme: with a single theme a block stays dark on a light
11
11
  * page. Given two, Shiki writes the light colour as an inline style and the
@@ -6,8 +6,8 @@
6
6
  * Shiki is what Astro already ships — the same highlighter VS Code renders
7
7
  * with — so nothing here adds a library; this only stops the two consumers
8
8
  * drifting onto different themes. The markdown pipeline reads it through
9
- * `astro.config`'s `shikiConfig`, and the `<Code>` component on the package
10
- * page takes it as a prop.
9
+ * `astro.config`'s `shikiConfig`, and `CodeBlock.astro` what every
10
+ * hand-written snippet on the site goes through — passes it to `<Code>`.
11
11
  *
12
12
  * A *pair*, not one theme: with a single theme a block stays dark on a light
13
13
  * page. Given two, Shiki writes the light colour as an inline style and the
@@ -7,8 +7,8 @@
7
7
  * Shiki is what Astro already ships — the same highlighter VS Code renders
8
8
  * with — so nothing here adds a library; this only stops the two consumers
9
9
  * drifting onto different themes. The markdown pipeline reads it through
10
- * `astro.config`'s `shikiConfig`, and the `<Code>` component on the package
11
- * page takes it as a prop.
10
+ * `astro.config`'s `shikiConfig`, and `CodeBlock.astro` what every
11
+ * hand-written snippet on the site goes through — passes it to `<Code>`.
12
12
  *
13
13
  * A *pair*, not one theme: with a single theme a block stays dark on a light
14
14
  * page. Given two, Shiki writes the light colour as an inline style and the
@@ -0,0 +1,64 @@
1
+ import type { RegistryHint, ResolvedSiteConfig } from "../../../config.js";
2
+ export type { RegistryHint };
3
+ /**
4
+ * One option in a command bar: what it is called, what it copies, and the
5
+ * glyph that stands for it.
6
+ *
7
+ * Icons arrive one of two ways and both render to static SVG at build time —
8
+ * `path` for an `@mdi/js` brand mark, `Icon` for a Lucide component. Lucide
9
+ * carries no brand marks and MDI is not the set the rest of the site draws
10
+ * with, so neither alone covers every bar.
11
+ */
12
+ export interface Choice {
13
+ /** Shown in the menu, and matched against `data-os-glyph` when picking. */
14
+ name: string;
15
+ /** What the field shows and copies once this choice is picked. */
16
+ command: string;
17
+ /** `@mdi/js` path string. */
18
+ path?: string;
19
+ /** Lucide component, taking a `size` prop. */
20
+ Icon?: (props: {
21
+ size?: number;
22
+ }) => unknown;
23
+ }
24
+ /**
25
+ * The installer choices, one per platform rather than one per config row.
26
+ *
27
+ * One row usually covers several platforms ("Linux / macOS"), and the toggle
28
+ * reads better as the platforms themselves than as the config's labels — so a
29
+ * row expands into one button per platform it names, all pointing at the same
30
+ * command. A row naming none (a custom label) keeps a single generic button,
31
+ * so an unrecognized platform is never dropped.
32
+ */
33
+ export declare function installChoices(config: ResolvedSiteConfig): Choice[];
34
+ /**
35
+ * The one command a visitor needs to point their own grim at this index —
36
+ * the `helm repo add` ergonomic. `null` when `registry` is unconfigured,
37
+ * which means the index has no public URL to hand out; the block is omitted
38
+ * rather than guessed.
39
+ *
40
+ * `registry` defaults to the site's own, and is a parameter so a page can draw
41
+ * the same bar for a *different* index: a corporate setup guide that hands out
42
+ * its own index in the hero and the public one further down needs two bars
43
+ * differing in nothing but this argument.
44
+ */
45
+ export declare function registryAddCommand(config: ResolvedSiteConfig, registry?: RegistryHint | null): string | null;
46
+ /**
47
+ * Scope choices for the registry-add bar. Empty when there is no command to
48
+ * scope — the bar can still render for the sake of its VS Code segment.
49
+ *
50
+ * Global leads: someone arriving from an index website wants this index
51
+ * available everywhere, not wired into whichever directory happens to be
52
+ * open, and it is also the scope that works with no project at all.
53
+ *
54
+ * `--global` leads the command rather than trailing it: it is a top-level
55
+ * flag, and appended after a long `--index <url>` it fell off the end of the
56
+ * line, so switching scope looked like it changed nothing at all.
57
+ */
58
+ export declare function registryScopeChoices(config: ResolvedSiteConfig, registry?: RegistryHint | null): Choice[];
59
+ /**
60
+ * Scope choices for adding one package — the same two-way choice, the same
61
+ * two glyphs, the same order as the registry bar and the package cards.
62
+ */
63
+ export declare function addArtifactChoices(ref: string): Choice[];
64
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1,91 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+ // What each command bar offers, derived from config. Separate from
4
+ // `./catalog.ts` on purpose: that file is documented as safe to pull into the
5
+ // client bundle and the catalog island imports it, while this one imports
6
+ // icon components and is only ever read by an `.astro` page at build time.
7
+ //
8
+ // It exists so an index's own page can draw the site's real install or
9
+ // registry bar in three lines rather than restating the command:
10
+ //
11
+ // import CommandBar from "@grim/components/CommandBar.astro";
12
+ // import { installChoices } from "@grim/lib/commands";
13
+ // import { data } from "@grim/lib/data";
14
+ // <CommandBar choices={installChoices(data.config)} noun="install command" … />
15
+ import { FolderRoot, Globe } from "lucide-preact";
16
+ import { mdiApple, mdiConsole, mdiMicrosoftWindows, mdiPenguin, } from "@mdi/js";
17
+ /**
18
+ * `mdiPenguin`, not `mdiLinux`: Tux's own silhouette is a solid mass that
19
+ * reads as an indistinct blob at 16px, where the plainer penguin keeps its
20
+ * outline. Still a brand mark from the same set, so it sits beside Apple and
21
+ * Windows without a weight change.
22
+ */
23
+ const PLATFORMS = [
24
+ { match: /linux/i, name: "Linux", path: mdiPenguin },
25
+ { match: /mac|darwin|osx|apple/i, name: "macOS", path: mdiApple },
26
+ { match: /win/i, name: "Windows", path: mdiMicrosoftWindows },
27
+ ];
28
+ /**
29
+ * The installer choices, one per platform rather than one per config row.
30
+ *
31
+ * One row usually covers several platforms ("Linux / macOS"), and the toggle
32
+ * reads better as the platforms themselves than as the config's labels — so a
33
+ * row expands into one button per platform it names, all pointing at the same
34
+ * command. A row naming none (a custom label) keeps a single generic button,
35
+ * so an unrecognized platform is never dropped.
36
+ */
37
+ export function installChoices(config) {
38
+ return config.install.flatMap((row) => {
39
+ const hits = PLATFORMS.filter((p) => p.match.test(row.os));
40
+ return hits.length > 0
41
+ ? hits.map((p) => ({ name: p.name, path: p.path, command: row.command }))
42
+ : [{ name: row.os, path: mdiConsole, command: row.command }];
43
+ });
44
+ }
45
+ /**
46
+ * The one command a visitor needs to point their own grim at this index —
47
+ * the `helm repo add` ergonomic. `null` when `registry` is unconfigured,
48
+ * which means the index has no public URL to hand out; the block is omitted
49
+ * rather than guessed.
50
+ *
51
+ * `registry` defaults to the site's own, and is a parameter so a page can draw
52
+ * the same bar for a *different* index: a corporate setup guide that hands out
53
+ * its own index in the hero and the public one further down needs two bars
54
+ * differing in nothing but this argument.
55
+ */
56
+ export function registryAddCommand(config, registry = config.registry) {
57
+ return registry
58
+ ? `grim config registry add ${registry.alias} --index ${registry.index}`
59
+ : null;
60
+ }
61
+ /**
62
+ * Scope choices for the registry-add bar. Empty when there is no command to
63
+ * scope — the bar can still render for the sake of its VS Code segment.
64
+ *
65
+ * Global leads: someone arriving from an index website wants this index
66
+ * available everywhere, not wired into whichever directory happens to be
67
+ * open, and it is also the scope that works with no project at all.
68
+ *
69
+ * `--global` leads the command rather than trailing it: it is a top-level
70
+ * flag, and appended after a long `--index <url>` it fell off the end of the
71
+ * line, so switching scope looked like it changed nothing at all.
72
+ */
73
+ export function registryScopeChoices(config, registry = config.registry) {
74
+ const add = registryAddCommand(config, registry);
75
+ if (!add)
76
+ return [];
77
+ return [
78
+ { name: "Global", command: `grim --global ${add.slice("grim ".length)}`, Icon: Globe },
79
+ { name: "Project", command: add, Icon: FolderRoot },
80
+ ];
81
+ }
82
+ /**
83
+ * Scope choices for adding one package — the same two-way choice, the same
84
+ * two glyphs, the same order as the registry bar and the package cards.
85
+ */
86
+ export function addArtifactChoices(ref) {
87
+ return [
88
+ { name: "Global", command: `grim add --global ${ref}`, Icon: Globe },
89
+ { name: "Project", command: `grim add ${ref}`, Icon: FolderRoot },
90
+ ];
91
+ }
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+
4
+ // What each command bar offers, derived from config. Separate from
5
+ // `./catalog.ts` on purpose: that file is documented as safe to pull into the
6
+ // client bundle and the catalog island imports it, while this one imports
7
+ // icon components and is only ever read by an `.astro` page at build time.
8
+ //
9
+ // It exists so an index's own page can draw the site's real install or
10
+ // registry bar in three lines rather than restating the command:
11
+ //
12
+ // import CommandBar from "@grim/components/CommandBar.astro";
13
+ // import { installChoices } from "@grim/lib/commands";
14
+ // import { data } from "@grim/lib/data";
15
+ // <CommandBar choices={installChoices(data.config)} noun="install command" … />
16
+ import { FolderRoot, Globe } from "lucide-preact";
17
+ import {
18
+ mdiApple,
19
+ mdiConsole,
20
+ mdiMicrosoftWindows,
21
+ mdiPenguin,
22
+ } from "@mdi/js";
23
+ import type { RegistryHint, ResolvedSiteConfig } from "../../../config.js";
24
+
25
+ // Re-exported so an index's own page can name the shape it passes to the two
26
+ // functions below without reaching past `@grim/lib/*` into the package's
27
+ // internals, which the overlay does not publish.
28
+ export type { RegistryHint };
29
+
30
+ /**
31
+ * One option in a command bar: what it is called, what it copies, and the
32
+ * glyph that stands for it.
33
+ *
34
+ * Icons arrive one of two ways and both render to static SVG at build time —
35
+ * `path` for an `@mdi/js` brand mark, `Icon` for a Lucide component. Lucide
36
+ * carries no brand marks and MDI is not the set the rest of the site draws
37
+ * with, so neither alone covers every bar.
38
+ */
39
+ export interface Choice {
40
+ /** Shown in the menu, and matched against `data-os-glyph` when picking. */
41
+ name: string;
42
+ /** What the field shows and copies once this choice is picked. */
43
+ command: string;
44
+ /** `@mdi/js` path string. */
45
+ path?: string;
46
+ /** Lucide component, taking a `size` prop. */
47
+ Icon?: (props: { size?: number }) => unknown;
48
+ }
49
+
50
+ /**
51
+ * `mdiPenguin`, not `mdiLinux`: Tux's own silhouette is a solid mass that
52
+ * reads as an indistinct blob at 16px, where the plainer penguin keeps its
53
+ * outline. Still a brand mark from the same set, so it sits beside Apple and
54
+ * Windows without a weight change.
55
+ */
56
+ const PLATFORMS = [
57
+ { match: /linux/i, name: "Linux", path: mdiPenguin },
58
+ { match: /mac|darwin|osx|apple/i, name: "macOS", path: mdiApple },
59
+ { match: /win/i, name: "Windows", path: mdiMicrosoftWindows },
60
+ ];
61
+
62
+ /**
63
+ * The installer choices, one per platform rather than one per config row.
64
+ *
65
+ * One row usually covers several platforms ("Linux / macOS"), and the toggle
66
+ * reads better as the platforms themselves than as the config's labels — so a
67
+ * row expands into one button per platform it names, all pointing at the same
68
+ * command. A row naming none (a custom label) keeps a single generic button,
69
+ * so an unrecognized platform is never dropped.
70
+ */
71
+ export function installChoices(config: ResolvedSiteConfig): Choice[] {
72
+ return config.install.flatMap((row) => {
73
+ const hits = PLATFORMS.filter((p) => p.match.test(row.os));
74
+ return hits.length > 0
75
+ ? hits.map((p) => ({ name: p.name, path: p.path, command: row.command }))
76
+ : [{ name: row.os, path: mdiConsole, command: row.command }];
77
+ });
78
+ }
79
+
80
+ /**
81
+ * The one command a visitor needs to point their own grim at this index —
82
+ * the `helm repo add` ergonomic. `null` when `registry` is unconfigured,
83
+ * which means the index has no public URL to hand out; the block is omitted
84
+ * rather than guessed.
85
+ *
86
+ * `registry` defaults to the site's own, and is a parameter so a page can draw
87
+ * the same bar for a *different* index: a corporate setup guide that hands out
88
+ * its own index in the hero and the public one further down needs two bars
89
+ * differing in nothing but this argument.
90
+ */
91
+ export function registryAddCommand(
92
+ config: ResolvedSiteConfig,
93
+ registry: RegistryHint | null = config.registry,
94
+ ): string | null {
95
+ return registry
96
+ ? `grim config registry add ${registry.alias} --index ${registry.index}`
97
+ : null;
98
+ }
99
+
100
+ /**
101
+ * Scope choices for the registry-add bar. Empty when there is no command to
102
+ * scope — the bar can still render for the sake of its VS Code segment.
103
+ *
104
+ * Global leads: someone arriving from an index website wants this index
105
+ * available everywhere, not wired into whichever directory happens to be
106
+ * open, and it is also the scope that works with no project at all.
107
+ *
108
+ * `--global` leads the command rather than trailing it: it is a top-level
109
+ * flag, and appended after a long `--index <url>` it fell off the end of the
110
+ * line, so switching scope looked like it changed nothing at all.
111
+ */
112
+ export function registryScopeChoices(
113
+ config: ResolvedSiteConfig,
114
+ registry: RegistryHint | null = config.registry,
115
+ ): Choice[] {
116
+ const add = registryAddCommand(config, registry);
117
+ if (!add) return [];
118
+ return [
119
+ { name: "Global", command: `grim --global ${add.slice("grim ".length)}`, Icon: Globe },
120
+ { name: "Project", command: add, Icon: FolderRoot },
121
+ ];
122
+ }
123
+
124
+ /**
125
+ * Scope choices for adding one package — the same two-way choice, the same
126
+ * two glyphs, the same order as the registry bar and the package cards.
127
+ */
128
+ export function addArtifactChoices(ref: string): Choice[] {
129
+ return [
130
+ { name: "Global", command: `grim add --global ${ref}`, Icon: Globe },
131
+ { name: "Project", command: `grim add ${ref}`, Icon: FolderRoot },
132
+ ];
133
+ }
@@ -0,0 +1,44 @@
1
+ export interface KeywordChip {
2
+ keyword: string;
3
+ count: number;
4
+ }
5
+ /**
6
+ * The rail's chips, picked by SPLITTING POWER rather than raw frequency.
7
+ *
8
+ * A chip is worth a rail slot when clicking it meaningfully partitions what
9
+ * is on screen, so:
10
+ *
11
+ * - a near-ubiquitous keyword scores ~0 (clicking it barely narrows),
12
+ * - a tiny keyword scores low (it barely selects anything),
13
+ * - a keyword redundant with one already picked scores low (its packages are
14
+ * already reachable through that one).
15
+ *
16
+ * Greedy. Each round scores every unpicked keyword as
17
+ * `min(uncoveredCount, total - count)` — a tent over coverage, peaking near
18
+ * half the set, intersected with the marginal gain over what is already
19
+ * covered — and takes the best. Ties go to the higher total count, then
20
+ * alphabetically. When nothing scores above zero (a small or homogeneous
21
+ * catalog) the remaining slots are padded by plain frequency, so the rail is
22
+ * never emptier than it has to be.
23
+ *
24
+ * The input is deliberately narrow — only `keywords` is read — so the tests
25
+ * need no full `CatalogPackage` fixtures.
26
+ *
27
+ * Ported from `@ocx-sh/catalog`'s `src/theme/utils/keywordRail.ts`, which
28
+ * arrived at this after a frequency-ranked rail kept offering the tags every
29
+ * package already carries.
30
+ */
31
+ export declare function selectRailKeywords(items: readonly {
32
+ keywords?: readonly string[];
33
+ }[], limit: number): KeywordChip[];
34
+ /**
35
+ * Every keyword in `items`, most common first. Feeds the overflow menu, which
36
+ * lists what the rail had no room for.
37
+ *
38
+ * Scored over the same set the rail is, so a menu row is never a click to an
39
+ * empty catalog: a keyword no surviving package carries is simply not there.
40
+ */
41
+ export declare function keywordFrequency(items: readonly {
42
+ keywords?: readonly string[];
43
+ }[]): KeywordChip[];
44
+ //# sourceMappingURL=keywordRail.d.ts.map