@grimoire-rs/indexer 0.4.4 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/NOTICE +30 -0
  3. package/README.md +76 -331
  4. package/dist/cli/init.d.ts.map +1 -1
  5. package/dist/cli/init.js +35 -4
  6. package/dist/cli/init.js.map +1 -1
  7. package/dist/config.d.ts +107 -7
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +181 -36
  10. package/dist/config.js.map +1 -1
  11. package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
  12. package/dist/renderer/astro/components/CardLogo.js +58 -0
  13. package/dist/renderer/astro/components/CardLogo.tsx +96 -0
  14. package/dist/renderer/astro/components/Catalog.d.ts +14 -1
  15. package/dist/renderer/astro/components/Catalog.js +467 -108
  16. package/dist/renderer/astro/components/Catalog.tsx +756 -349
  17. package/dist/renderer/astro/components/CommandBar.astro +66 -0
  18. package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
  19. package/dist/renderer/astro/components/CopyButton.js +28 -0
  20. package/dist/renderer/astro/components/CopyButton.tsx +56 -0
  21. package/dist/renderer/astro/components/KindMark.d.ts +69 -0
  22. package/dist/renderer/astro/components/KindMark.js +66 -0
  23. package/dist/renderer/astro/components/KindMark.tsx +141 -0
  24. package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
  25. package/dist/renderer/astro/components/PackageCard.js +50 -0
  26. package/dist/renderer/astro/components/PackageCard.tsx +273 -0
  27. package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
  28. package/dist/renderer/astro/components/PackageRow.js +32 -0
  29. package/dist/renderer/astro/components/PackageRow.tsx +126 -0
  30. package/dist/renderer/astro/components/PickerMenu.astro +5 -14
  31. package/dist/renderer/astro/components/SiteFooter.astro +64 -0
  32. package/dist/renderer/astro/components/SiteHeader.astro +74 -0
  33. package/dist/renderer/astro/components/VersionMenu.astro +2 -2
  34. package/dist/renderer/astro/layouts/Base.astro +860 -206
  35. package/dist/renderer/astro/lib/base.d.ts +25 -0
  36. package/dist/renderer/astro/lib/base.js +23 -0
  37. package/dist/renderer/astro/lib/base.ts +27 -0
  38. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  39. package/dist/renderer/astro/lib/catalog.js +36 -0
  40. package/dist/renderer/astro/lib/catalog.ts +37 -0
  41. package/dist/renderer/astro/lib/commands.d.ts +58 -0
  42. package/dist/renderer/astro/lib/commands.js +86 -0
  43. package/dist/renderer/astro/lib/commands.ts +117 -0
  44. package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
  45. package/dist/renderer/astro/lib/keywordRail.js +99 -0
  46. package/dist/renderer/astro/lib/keywordRail.ts +110 -0
  47. package/dist/renderer/astro/pages/index.astro +40 -87
  48. package/dist/renderer/astro/pages/p/[...slug].astro +340 -195
  49. package/dist/renderer/astro/styles/tokens.css +40 -5
  50. package/dist/renderer/index.d.ts +58 -0
  51. package/dist/renderer/index.d.ts.map +1 -1
  52. package/dist/renderer/index.js +547 -5
  53. package/dist/renderer/index.js.map +1 -1
  54. package/dist/renderer/types.d.ts +9 -0
  55. package/dist/renderer/types.d.ts.map +1 -1
  56. package/package.json +9 -4
  57. package/templates/README.md +6 -0
  58. package/templates/gitignore +4 -1
  59. package/templates/theme/README.md +38 -0
  60. 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
@@ -0,0 +1,58 @@
1
+ import type { ResolvedSiteConfig } from "../../../config.js";
2
+ /**
3
+ * One option in a command bar: what it is called, what it copies, and the
4
+ * glyph that stands for it.
5
+ *
6
+ * Icons arrive one of two ways and both render to static SVG at build time —
7
+ * `path` for an `@mdi/js` brand mark, `Icon` for a Lucide component. Lucide
8
+ * carries no brand marks and MDI is not the set the rest of the site draws
9
+ * with, so neither alone covers every bar.
10
+ */
11
+ export interface Choice {
12
+ /** Shown in the menu, and matched against `data-os-glyph` when picking. */
13
+ name: string;
14
+ /** What the field shows and copies once this choice is picked. */
15
+ command: string;
16
+ /** `@mdi/js` path string. */
17
+ path?: string;
18
+ /** Lucide component, taking a `size` prop. */
19
+ Icon?: (props: {
20
+ size?: number;
21
+ }) => unknown;
22
+ }
23
+ /**
24
+ * The installer choices, one per platform rather than one per config row.
25
+ *
26
+ * One row usually covers several platforms ("Linux / macOS"), and the toggle
27
+ * reads better as the platforms themselves than as the config's labels — so a
28
+ * row expands into one button per platform it names, all pointing at the same
29
+ * command. A row naming none (a custom label) keeps a single generic button,
30
+ * so an unrecognized platform is never dropped.
31
+ */
32
+ export declare function installChoices(config: ResolvedSiteConfig): Choice[];
33
+ /**
34
+ * The one command a visitor needs to point their own grim at this index —
35
+ * the `helm repo add` ergonomic. `null` when `registry` is unconfigured,
36
+ * which means the index has no public URL to hand out; the block is omitted
37
+ * rather than guessed.
38
+ */
39
+ export declare function registryAddCommand(config: ResolvedSiteConfig): string | null;
40
+ /**
41
+ * Scope choices for the registry-add bar. Empty when there is no command to
42
+ * scope — the bar can still render for the sake of its VS Code segment.
43
+ *
44
+ * Global leads: someone arriving from an index website wants this index
45
+ * available everywhere, not wired into whichever directory happens to be
46
+ * open, and it is also the scope that works with no project at all.
47
+ *
48
+ * `--global` leads the command rather than trailing it: it is a top-level
49
+ * flag, and appended after a long `--index <url>` it fell off the end of the
50
+ * line, so switching scope looked like it changed nothing at all.
51
+ */
52
+ export declare function registryScopeChoices(config: ResolvedSiteConfig): Choice[];
53
+ /**
54
+ * Scope choices for adding one package — the same two-way choice, the same
55
+ * two glyphs, the same order as the registry bar and the package cards.
56
+ */
57
+ export declare function addArtifactChoices(ref: string): Choice[];
58
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1,86 @@
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
+ export function registryAddCommand(config) {
52
+ return config.registry
53
+ ? `grim config registry add ${config.registry.alias} --index ${config.registry.index}`
54
+ : null;
55
+ }
56
+ /**
57
+ * Scope choices for the registry-add bar. Empty when there is no command to
58
+ * scope — the bar can still render for the sake of its VS Code segment.
59
+ *
60
+ * Global leads: someone arriving from an index website wants this index
61
+ * available everywhere, not wired into whichever directory happens to be
62
+ * open, and it is also the scope that works with no project at all.
63
+ *
64
+ * `--global` leads the command rather than trailing it: it is a top-level
65
+ * flag, and appended after a long `--index <url>` it fell off the end of the
66
+ * line, so switching scope looked like it changed nothing at all.
67
+ */
68
+ export function registryScopeChoices(config) {
69
+ const add = registryAddCommand(config);
70
+ if (!add)
71
+ return [];
72
+ return [
73
+ { name: "Global", command: `grim --global ${add.slice("grim ".length)}`, Icon: Globe },
74
+ { name: "Project", command: add, Icon: FolderRoot },
75
+ ];
76
+ }
77
+ /**
78
+ * Scope choices for adding one package — the same two-way choice, the same
79
+ * two glyphs, the same order as the registry bar and the package cards.
80
+ */
81
+ export function addArtifactChoices(ref) {
82
+ return [
83
+ { name: "Global", command: `grim add --global ${ref}`, Icon: Globe },
84
+ { name: "Project", command: `grim add ${ref}`, Icon: FolderRoot },
85
+ ];
86
+ }
@@ -0,0 +1,117 @@
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 { ResolvedSiteConfig } from "../../../config.js";
24
+
25
+ /**
26
+ * One option in a command bar: what it is called, what it copies, and the
27
+ * glyph that stands for it.
28
+ *
29
+ * Icons arrive one of two ways and both render to static SVG at build time —
30
+ * `path` for an `@mdi/js` brand mark, `Icon` for a Lucide component. Lucide
31
+ * carries no brand marks and MDI is not the set the rest of the site draws
32
+ * with, so neither alone covers every bar.
33
+ */
34
+ export interface Choice {
35
+ /** Shown in the menu, and matched against `data-os-glyph` when picking. */
36
+ name: string;
37
+ /** What the field shows and copies once this choice is picked. */
38
+ command: string;
39
+ /** `@mdi/js` path string. */
40
+ path?: string;
41
+ /** Lucide component, taking a `size` prop. */
42
+ Icon?: (props: { size?: number }) => unknown;
43
+ }
44
+
45
+ /**
46
+ * `mdiPenguin`, not `mdiLinux`: Tux's own silhouette is a solid mass that
47
+ * reads as an indistinct blob at 16px, where the plainer penguin keeps its
48
+ * outline. Still a brand mark from the same set, so it sits beside Apple and
49
+ * Windows without a weight change.
50
+ */
51
+ const PLATFORMS = [
52
+ { match: /linux/i, name: "Linux", path: mdiPenguin },
53
+ { match: /mac|darwin|osx|apple/i, name: "macOS", path: mdiApple },
54
+ { match: /win/i, name: "Windows", path: mdiMicrosoftWindows },
55
+ ];
56
+
57
+ /**
58
+ * The installer choices, one per platform rather than one per config row.
59
+ *
60
+ * One row usually covers several platforms ("Linux / macOS"), and the toggle
61
+ * reads better as the platforms themselves than as the config's labels — so a
62
+ * row expands into one button per platform it names, all pointing at the same
63
+ * command. A row naming none (a custom label) keeps a single generic button,
64
+ * so an unrecognized platform is never dropped.
65
+ */
66
+ export function installChoices(config: ResolvedSiteConfig): Choice[] {
67
+ return config.install.flatMap((row) => {
68
+ const hits = PLATFORMS.filter((p) => p.match.test(row.os));
69
+ return hits.length > 0
70
+ ? hits.map((p) => ({ name: p.name, path: p.path, command: row.command }))
71
+ : [{ name: row.os, path: mdiConsole, command: row.command }];
72
+ });
73
+ }
74
+
75
+ /**
76
+ * The one command a visitor needs to point their own grim at this index —
77
+ * the `helm repo add` ergonomic. `null` when `registry` is unconfigured,
78
+ * which means the index has no public URL to hand out; the block is omitted
79
+ * rather than guessed.
80
+ */
81
+ export function registryAddCommand(config: ResolvedSiteConfig): string | null {
82
+ return config.registry
83
+ ? `grim config registry add ${config.registry.alias} --index ${config.registry.index}`
84
+ : null;
85
+ }
86
+
87
+ /**
88
+ * Scope choices for the registry-add bar. Empty when there is no command to
89
+ * scope — the bar can still render for the sake of its VS Code segment.
90
+ *
91
+ * Global leads: someone arriving from an index website wants this index
92
+ * available everywhere, not wired into whichever directory happens to be
93
+ * open, and it is also the scope that works with no project at all.
94
+ *
95
+ * `--global` leads the command rather than trailing it: it is a top-level
96
+ * flag, and appended after a long `--index <url>` it fell off the end of the
97
+ * line, so switching scope looked like it changed nothing at all.
98
+ */
99
+ export function registryScopeChoices(config: ResolvedSiteConfig): Choice[] {
100
+ const add = registryAddCommand(config);
101
+ if (!add) return [];
102
+ return [
103
+ { name: "Global", command: `grim --global ${add.slice("grim ".length)}`, Icon: Globe },
104
+ { name: "Project", command: add, Icon: FolderRoot },
105
+ ];
106
+ }
107
+
108
+ /**
109
+ * Scope choices for adding one package — the same two-way choice, the same
110
+ * two glyphs, the same order as the registry bar and the package cards.
111
+ */
112
+ export function addArtifactChoices(ref: string): Choice[] {
113
+ return [
114
+ { name: "Global", command: `grim add --global ${ref}`, Icon: Globe },
115
+ { name: "Project", command: `grim add ${ref}`, Icon: FolderRoot },
116
+ ];
117
+ }
@@ -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
@@ -0,0 +1,99 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+ /**
4
+ * The rail's chips, picked by SPLITTING POWER rather than raw frequency.
5
+ *
6
+ * A chip is worth a rail slot when clicking it meaningfully partitions what
7
+ * is on screen, so:
8
+ *
9
+ * - a near-ubiquitous keyword scores ~0 (clicking it barely narrows),
10
+ * - a tiny keyword scores low (it barely selects anything),
11
+ * - a keyword redundant with one already picked scores low (its packages are
12
+ * already reachable through that one).
13
+ *
14
+ * Greedy. Each round scores every unpicked keyword as
15
+ * `min(uncoveredCount, total - count)` — a tent over coverage, peaking near
16
+ * half the set, intersected with the marginal gain over what is already
17
+ * covered — and takes the best. Ties go to the higher total count, then
18
+ * alphabetically. When nothing scores above zero (a small or homogeneous
19
+ * catalog) the remaining slots are padded by plain frequency, so the rail is
20
+ * never emptier than it has to be.
21
+ *
22
+ * The input is deliberately narrow — only `keywords` is read — so the tests
23
+ * need no full `CatalogPackage` fixtures.
24
+ *
25
+ * Ported from `@ocx-sh/catalog`'s `src/theme/utils/keywordRail.ts`, which
26
+ * arrived at this after a frequency-ranked rail kept offering the tags every
27
+ * package already carries.
28
+ */
29
+ export function selectRailKeywords(items, limit) {
30
+ const total = items.length;
31
+ const members = new Map();
32
+ items.forEach((item, i) => {
33
+ for (const kw of item.keywords ?? []) {
34
+ let set = members.get(kw);
35
+ if (!set)
36
+ members.set(kw, (set = new Set()));
37
+ set.add(i);
38
+ }
39
+ });
40
+ const picked = [];
41
+ const pickedSet = new Set();
42
+ const covered = new Set();
43
+ while (picked.length < limit) {
44
+ let best = null;
45
+ let bestScore = 0;
46
+ let bestCount = 0;
47
+ for (const [kw, set] of members) {
48
+ if (pickedSet.has(kw))
49
+ continue;
50
+ let uncovered = 0;
51
+ for (const i of set)
52
+ if (!covered.has(i))
53
+ uncovered++;
54
+ const score = Math.min(uncovered, total - set.size);
55
+ const wins = score > bestScore ||
56
+ (score === bestScore &&
57
+ score > 0 &&
58
+ (set.size > bestCount ||
59
+ (set.size === bestCount && best !== null && kw < best)));
60
+ if (wins) {
61
+ best = kw;
62
+ bestScore = score;
63
+ bestCount = set.size;
64
+ }
65
+ }
66
+ if (best === null)
67
+ break;
68
+ picked.push({ keyword: best, count: members.get(best).size });
69
+ pickedSet.add(best);
70
+ for (const i of members.get(best))
71
+ covered.add(i);
72
+ }
73
+ // Frequency padding for the slots the greedy pass could not justify.
74
+ if (picked.length < limit) {
75
+ const rest = [...members]
76
+ .filter(([kw]) => !pickedSet.has(kw))
77
+ .map(([keyword, set]) => ({ keyword, count: set.size }))
78
+ .sort((a, b) => b.count - a.count || a.keyword.localeCompare(b.keyword));
79
+ picked.push(...rest.slice(0, limit - picked.length));
80
+ }
81
+ return picked;
82
+ }
83
+ /**
84
+ * Every keyword in `items`, most common first. Feeds the overflow menu, which
85
+ * lists what the rail had no room for.
86
+ *
87
+ * Scored over the same set the rail is, so a menu row is never a click to an
88
+ * empty catalog: a keyword no surviving package carries is simply not there.
89
+ */
90
+ export function keywordFrequency(items) {
91
+ const counts = new Map();
92
+ for (const item of items) {
93
+ for (const kw of item.keywords ?? [])
94
+ counts.set(kw, (counts.get(kw) ?? 0) + 1);
95
+ }
96
+ return [...counts]
97
+ .map(([keyword, count]) => ({ keyword, count }))
98
+ .sort((a, b) => b.count - a.count || a.keyword.localeCompare(b.keyword));
99
+ }