@grove-dev/astro 0.5.0-next.0 → 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 (72) hide show
  1. package/dist/index.js +2 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/index.d.ts +0 -2
  4. package/dist/lib/index.d.ts.map +1 -1
  5. package/dist/lib/index.js +0 -2
  6. package/dist/lib/index.js.map +1 -1
  7. package/dist/ui/button.d.ts +45 -0
  8. package/dist/ui/button.d.ts.map +1 -0
  9. package/dist/ui/button.js +82 -0
  10. package/dist/ui/button.js.map +1 -0
  11. package/package.json +8 -5
  12. package/src/components/CardGrid.astro +41 -0
  13. package/src/components/CardIcon.astro +67 -0
  14. package/src/components/CategoryGrid.astro +20 -5
  15. package/src/components/CollectionCard.astro +56 -0
  16. package/src/components/CollectionIndex.astro +21 -26
  17. package/src/components/CollectionPage.astro +36 -13
  18. package/src/components/CollectionRow.astro +62 -192
  19. package/src/components/CollectionTeaser.astro +4 -0
  20. package/src/components/ContributorsGrid.astro +4 -1
  21. package/src/components/DirectoryIndexClient.astro +127 -30
  22. package/src/components/EditorialSummary.astro +6 -5
  23. package/src/components/FilterGroupMenu.astro +24 -10
  24. package/src/components/FilterOptions.astro +6 -1
  25. package/src/components/FinalCta.astro +5 -3
  26. package/src/components/Hero.astro +85 -139
  27. package/src/components/IndexRow.astro +36 -69
  28. package/src/components/LanguageBreakdown.astro +5 -1
  29. package/src/components/OriginalCollection.astro +3 -2
  30. package/src/components/Pagination.astro +7 -20
  31. package/src/components/ProjectCard.astro +345 -0
  32. package/src/components/RecordHeader.astro +137 -77
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +80 -22
  35. package/src/components/RefinePanel.astro +41 -62
  36. package/src/components/SmartLensTabs.astro +3 -7
  37. package/src/components/StackGrid.astro +26 -6
  38. package/src/components/SubmissionClient.astro +173 -52
  39. package/src/components/TableOfContents.astro +31 -5
  40. package/src/components/WhyThisExists.astro +1 -1
  41. package/src/index.ts +2 -2
  42. package/src/layouts/BaseLayout.astro +79 -9
  43. package/src/layouts/Header.astro +5 -4
  44. package/src/layouts/SectionHeader.astro +3 -2
  45. package/src/layouts/Seo.astro +28 -17
  46. package/src/layouts/ThemeToggle.astro +23 -6
  47. package/src/lib/index.ts +0 -2
  48. package/src/server/collections.ts +88 -1
  49. package/src/server/contrast.test.ts +82 -0
  50. package/src/server/contrast.ts +117 -0
  51. package/src/server/directory.test.ts +177 -0
  52. package/src/server/directory.ts +328 -206
  53. package/src/server/github-repo.test.ts +88 -0
  54. package/src/server/github-repo.ts +104 -0
  55. package/src/server/index.ts +4 -3
  56. package/src/server/models-home.test.ts +101 -0
  57. package/src/server/models.test.ts +135 -0
  58. package/src/server/models.ts +192 -43
  59. package/src/styles.css +128 -16
  60. package/dist/lib/scores.d.ts +0 -2
  61. package/dist/lib/scores.d.ts.map +0 -1
  62. package/dist/lib/scores.js +0 -2
  63. package/dist/lib/scores.js.map +0 -1
  64. package/src/components/CurationGrid.astro +0 -41
  65. package/src/components/DecisionRow.astro +0 -89
  66. package/src/components/ExploreByCategory.astro +0 -67
  67. package/src/components/ExploreByStack.astro +0 -78
  68. package/src/components/GroveDocumentHead.astro +0 -28
  69. package/src/components/ItemCard.astro +0 -323
  70. package/src/components/MinimalAbout.astro +0 -82
  71. package/src/components/ScoreBars.astro +0 -102
  72. package/src/lib/scores.ts +0 -1
@@ -31,7 +31,7 @@ interface Props {
31
31
  /** Open Graph / Twitter image. Default: /og-image.svg. Should be absolute or root-relative. */
32
32
  image?: string;
33
33
  /** og:type. Default: "website". Use "article" for blog posts / changelog. */
34
- type?: "website" | "article" | "profile";
34
+ type?: 'website' | 'article' | 'profile';
35
35
  /** Set true to add noindex,nofollow. */
36
36
  noindex?: boolean;
37
37
  /** Extra JSON-LD payload (object) — typically SoftwareSourceCode on item pages. */
@@ -42,21 +42,32 @@ const {
42
42
  title,
43
43
  description,
44
44
  site: siteConfig,
45
- searchPath = "/items",
46
- image = "/og-image.svg",
47
- type = "website",
45
+ searchPath = '/items',
46
+ image = '/og-image.svg',
47
+ type = 'website',
48
48
  noindex = false,
49
49
  jsonLd,
50
50
  } = Astro.props;
51
51
 
52
- const siteUrl = Astro.site ?? new URL("https://example.com");
52
+ // `Astro.site` is required for canonical / OG / Twitter / JSON-LD URLs to
53
+ // resolve to the consumer's domain. Failing the build (rather than silently
54
+ // emitting `https://example.com/...`) is the right call: every canonical,
55
+ // OG, and JSON-LD URL would otherwise leak to a domain the consumer doesn't
56
+ // own, which search engines either ignore or treat as canonical to the
57
+ // wrong host.
58
+ if (!Astro.site) {
59
+ throw new Error(
60
+ "Seo: `site` is not set on the Astro config. Add `site: 'https://your-domain'` to astro.config.mjs so canonical / Open Graph / Twitter / JSON-LD URLs resolve correctly.",
61
+ );
62
+ }
63
+ const siteUrl = Astro.site;
53
64
  const canonical = new URL(Astro.url.pathname, siteUrl).toString();
54
65
  const ogImage = new URL(image, siteUrl).toString();
55
66
 
56
67
  // Truncate description for OG/Twitter where longer is worse (160–200 chars).
57
68
  function truncate(s: string, max: number): string {
58
69
  if (s.length <= max) return s;
59
- return s.slice(0, max - 1).trimEnd() + "\u2026";
70
+ return s.slice(0, max - 1).trimEnd() + '\u2026';
60
71
  }
61
72
  const shortDescription = truncate(description, 200);
62
73
 
@@ -64,25 +75,25 @@ const shortDescription = truncate(description, 200);
64
75
  // that should appear on every page (Google uses them for site links
65
76
  // and the knowledge panel).
66
77
  const websiteLd = {
67
- "@context": "https://schema.org",
68
- "@type": "WebSite",
78
+ '@context': 'https://schema.org',
79
+ '@type': 'WebSite',
69
80
  name: siteConfig.name,
70
81
  description: siteConfig.description ?? description,
71
- url: siteUrl.toString().replace(/\/$/, ""),
72
- inLanguage: "en",
82
+ url: siteUrl.toString().replace(/\/$/, ''),
83
+ inLanguage: 'en',
73
84
  potentialAction: {
74
- "@type": "SearchAction",
75
- target: `${siteUrl.toString().replace(/\/$/, "")}${searchPath}?q={search_term_string}`,
76
- "query-input": "required name=search_term_string",
85
+ '@type': 'SearchAction',
86
+ target: `${siteUrl.toString().replace(/\/$/, '')}${searchPath}?q={search_term_string}`,
87
+ 'query-input': 'required name=search_term_string',
77
88
  },
78
89
  };
79
90
 
80
91
  const organizationLd = {
81
- "@context": "https://schema.org",
82
- "@type": "Organization",
92
+ '@context': 'https://schema.org',
93
+ '@type': 'Organization',
83
94
  name: siteConfig.name,
84
- url: siteUrl.toString().replace(/\/$/, ""),
85
- logo: new URL("/og-image.svg", siteUrl).toString(),
95
+ url: siteUrl.toString().replace(/\/$/, ''),
96
+ logo: new URL('/og-image.svg', siteUrl).toString(),
86
97
  ...(siteConfig.repoUrl ? { sameAs: [siteConfig.repoUrl] } : {}),
87
98
  };
88
99
 
@@ -22,7 +22,7 @@ const { class: className = "" } = Astro.props;
22
22
  type="button"
23
23
  id="theme-toggle"
24
24
  data-theme-toggle
25
- aria-label="Toggle theme"
25
+ aria-label="Theme: system — switch to light"
26
26
  title="Toggle theme"
27
27
  class={`inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-[var(--radius)] border border-border bg-secondary p-0 text-foreground outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 ${className}`}
28
28
  >
@@ -58,6 +58,9 @@ const { class: className = "" } = Astro.props;
58
58
  <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/>
59
59
  </svg>
60
60
  </button>
61
+ {/* Announces theme changes to assistive tech — the icon alone can't
62
+ distinguish "dark" from "system resolving to dark". */}
63
+ <span data-theme-toggle-status role="status" class="sr-only"></span>
61
64
 
62
65
  <script>
63
66
  // The toggle button simply rotates between the three modes on
@@ -82,18 +85,32 @@ const { class: className = "" } = Astro.props;
82
85
  return next;
83
86
  }
84
87
 
85
- // A small tooltip label so the user knows what mode they'll get
86
- // on the next click. We re-read current mode on focus/hover so it
87
- // stays in sync.
88
+ function nextMode(): Mode {
89
+ const order: Mode[] = ["light", "dark", "system"];
90
+ return order[(order.indexOf(currentMode()) + 1) % order.length];
91
+ }
92
+
93
+ // The accessible name carries BOTH the current state and the next
94
+ // action ("Theme: dark — switch to system"); title mirrors it for
95
+ // sighted tooltip users. Re-read on focus/hover so another tab or
96
+ // toggle instance can't leave it stale.
88
97
  function updateLabel(btn: HTMLButtonElement) {
89
- btn.title = `Theme: ${currentMode()} (click to switch)`;
98
+ const label = `Theme: ${currentMode()} switch to ${nextMode()}`;
99
+ btn.setAttribute("aria-label", label);
100
+ btn.title = label;
101
+ }
102
+
103
+ function announce(mode: Mode) {
104
+ const status = document.querySelector("[data-theme-toggle-status]");
105
+ if (status) status.textContent = `Theme set to ${mode}`;
90
106
  }
91
107
 
92
108
  document.querySelectorAll<HTMLButtonElement>("[data-theme-toggle]").forEach((btn) => {
93
109
  updateLabel(btn);
94
110
  btn.addEventListener("click", () => {
95
- rotate();
111
+ const mode = rotate();
96
112
  updateLabel(btn);
113
+ announce(mode);
97
114
  });
98
115
  btn.addEventListener("mouseenter", () => updateLabel(btn));
99
116
  btn.addEventListener("focus", () => updateLabel(btn));
package/src/lib/index.ts CHANGED
@@ -8,7 +8,6 @@
8
8
  * - format counts, dates, stars (`lib/format`)
9
9
  * - apply curated lenses / facets / search filters (`lib/lenses`,
10
10
  * `lib/search`)
11
- * - render score bars (`lib/scores`)
12
11
  * - compute taxonomy counts at build time (`lib/taxonomy-counts`)
13
12
  * - pretty-print slugs and label/lens/sort/status ids (`lib/display`)
14
13
  *
@@ -21,7 +20,6 @@ export * from "./repo.js";
21
20
  export * from "./format.js";
22
21
  export * from "./lenses.js";
23
22
  export * from "./search.js";
24
- export * from "./scores.js";
25
23
  export * from "./taxonomy-counts.js";
26
24
  export * from "./display.js";
27
25
  export * from "./load-collections.js";
@@ -18,8 +18,14 @@ interface RawRecord {
18
18
  stacks?: string[];
19
19
  platforms?: string[];
20
20
  license?: string;
21
+ licenses?: string[];
21
22
  visibility?: string;
23
+ /** GitHub stargazers count. Stored on `github.repository.stargazers_count`
24
+ * in the full payload; read explicitly because the index payload
25
+ * does not flatten it. */
22
26
  stars?: number;
27
+ /** GitHub fork count. Stored on `github.repository.forks_count`. */
28
+ forks?: number;
23
29
  pushedAt?: string;
24
30
  lastCommitAt?: string;
25
31
  category?: string;
@@ -27,6 +33,15 @@ interface RawRecord {
27
33
  scores?: { curation?: number; activity?: number };
28
34
  repoUrl?: string;
29
35
  links?: { github?: string; website?: string };
36
+ /** GitHub metadata block on the full record. The star / fork counts
37
+ * are read from `github.repository.stargazers_count` /
38
+ * `github.repository.forks_count` here. */
39
+ github?: {
40
+ repository?: {
41
+ stargazers_count?: number;
42
+ forks_count?: number;
43
+ };
44
+ };
30
45
  }
31
46
 
32
47
  /**
@@ -67,8 +82,19 @@ export function recordsToCollectionEntries(
67
82
  stack: r.stack ?? r.stacks?.[0],
68
83
  platform: r.platforms,
69
84
  license: r.license,
85
+ // Pass through the curated `licenses` array so the collection
86
+ // engine can match both curated SPDX ids and the GitHub fallback.
87
+ // If only `license` (singular, GitHub-synced) is present, mirror
88
+ // it into the array so the filter still matches it.
89
+ licenses:
90
+ r.licenses && r.licenses.length > 0
91
+ ? r.licenses
92
+ : r.license
93
+ ? [r.license]
94
+ : undefined,
70
95
  status: r.visibility,
71
- stars: r.stars,
96
+ stars: r.stars ?? r.github?.repository?.stargazers_count,
97
+ forks: r.forks ?? r.github?.repository?.forks_count,
72
98
  pushedAt: r.pushedAt ?? r.lastCommitAt,
73
99
  curationScore: r.scores?.curation,
74
100
  activityScore: r.scores?.activity,
@@ -97,6 +123,10 @@ export interface CollectionPageModel {
97
123
  total: number;
98
124
  isEmpty: boolean;
99
125
  entries: CollectionEntry[];
126
+ /** ItemList JSON-LD block. Pass to BaseLayout as the `jsonLd` prop
127
+ * so it ships in the document head and is indexable by search
128
+ * engines as a list of `SoftwareApplication` items. */
129
+ jsonLd?: unknown;
100
130
  related: Array<{ slug: string; title: string; url: string }>;
101
131
  }
102
132
 
@@ -121,6 +151,7 @@ export function getCollectionPageModel(
121
151
  collection: Collection,
122
152
  entries: CollectionEntry[],
123
153
  allCollections: Collection[],
154
+ site?: { name?: string; url?: string },
124
155
  ): CollectionPageModel {
125
156
  const result = runCollection(collection, entries);
126
157
  const related = findRelated(collection, allCollections, 4).map((c) => ({
@@ -128,6 +159,29 @@ export function getCollectionPageModel(
128
159
  title: c.title,
129
160
  url: `/collections/${c.slug}/`,
130
161
  }));
162
+ // ItemList JSON-LD. Search engines can use this to surface
163
+ // individual entries directly from the collection URL.
164
+ // Cap at 50 entries to keep the JSON-LD payload bounded;
165
+ // search engines don't index beyond that anyway.
166
+ const itemListElement = result.entries.slice(0, 50).map((entry, index) => ({
167
+ "@type": "ListItem",
168
+ position: index + 1,
169
+ item: {
170
+ "@type": "SoftwareApplication",
171
+ name: entry.title,
172
+ url: entry.url,
173
+ description: entry.description,
174
+ },
175
+ }));
176
+ const jsonLd = {
177
+ "@context": "https://schema.org",
178
+ "@type": "ItemList",
179
+ name: collection.title,
180
+ description: collection.description,
181
+ url: site?.url ? `${site.url.replace(/\/$/, "")}/collections/${collection.slug}/` : undefined,
182
+ numberOfItems: result.entries.length,
183
+ itemListElement,
184
+ };
131
185
  return {
132
186
  collection: {
133
187
  slug: collection.slug,
@@ -141,6 +195,7 @@ export function getCollectionPageModel(
141
195
  isEmpty: result.isEmpty,
142
196
  entries: result.entries,
143
197
  related,
198
+ jsonLd,
144
199
  };
145
200
  }
146
201
 
@@ -202,3 +257,35 @@ export async function loadCollections(cwd: string): Promise<Collection[]> {
202
257
  }
203
258
  return out;
204
259
  }
260
+
261
+ /**
262
+ * Reverse lookup — given a record, return the slugs of every
263
+ * curated collection that includes it. Walks each collection's
264
+ * query + ranking once, applies the same filter the collection
265
+ * page uses, and returns the collection slugs that contain the
266
+ * target record. Used by the detail page's sidebar to show
267
+ * "Also in" / "Collection membership" links.
268
+ *
269
+ * The returned array includes `{slug, title}` pairs so the
270
+ * sidebar can render both the link target and a user-facing
271
+ * label without re-loading the collection YAML.
272
+ */
273
+ export function findCollectionsFor(
274
+ target: { slug?: string; stack?: string; stacks?: string[]; platforms?: string[]; licenses?: string[]; category?: string; visibility?: string; status?: string },
275
+ collections: Collection[],
276
+ entries: CollectionEntry[],
277
+ ): { slug: string; title: string; url: string }[] {
278
+ if (!target.slug) return [];
279
+ const result: { slug: string; title: string; url: string }[] = [];
280
+ for (const collection of collections) {
281
+ const filtered = runCollection(collection, entries).entries;
282
+ if (filtered.some((entry) => entry.slug === target.slug)) {
283
+ result.push({
284
+ slug: collection.slug,
285
+ title: collection.title,
286
+ url: `/collections/${collection.slug}/`,
287
+ });
288
+ }
289
+ }
290
+ return result;
291
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { contrastRatio, derivePrimaryPalette, hexToRgb, relativeLuminance, rgbToHex } from "./contrast";
3
+
4
+ describe("WCAG contrast math", () => {
5
+ it("parses 3- and 6-digit hex and rejects garbage", () => {
6
+ expect(hexToRgb("#fff")).toEqual([255, 255, 255]);
7
+ expect(hexToRgb("#16a34a")).toEqual([0x16, 0xa3, 0x4a]);
8
+ expect(hexToRgb("#16a34aff")).toEqual([0x16, 0xa3, 0x4a]);
9
+ expect(hexToRgb("#xyz")).toBeNull();
10
+ expect(hexToRgb("green")).toBeNull();
11
+ });
12
+
13
+ it("computes relative luminance at the anchors", () => {
14
+ expect(relativeLuminance([0, 0, 0])).toBe(0);
15
+ expect(relativeLuminance([255, 255, 255])).toBeCloseTo(1, 5);
16
+ });
17
+
18
+ it("computes the canonical 21:1 black/white ratio", () => {
19
+ expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 1);
20
+ expect(contrastRatio("#ffffff", "#000000")).toBeCloseTo(21, 1);
21
+ });
22
+
23
+ it("confirms the audit finding: white on #16a34a fails AA", () => {
24
+ const ratio = contrastRatio("#16a34a", "#ffffff");
25
+ expect(ratio).toBeGreaterThan(3);
26
+ expect(ratio).toBeLessThan(4.5);
27
+ });
28
+ });
29
+
30
+ describe("derivePrimaryPalette", () => {
31
+ it("pairs #16a34a with AA text instead of white (the P0 fix)", () => {
32
+ // White on #16a34a is 3.3:1 (the shipped failure). Near-black
33
+ // reaches 6:1, so the brand hex stays exact and the TEXT changes.
34
+ const palette = derivePrimaryPalette("#16a34a");
35
+ expect(palette).not.toBeNull();
36
+ const p = palette as NonNullable<typeof palette>;
37
+ expect(p.solid).toBe("#16a34a");
38
+ expect(p.solidForeground).toBe("#0a0a0a");
39
+ expect(contrastRatio(p.solid, p.solidForeground)).toBeGreaterThanOrEqual(4.5);
40
+ });
41
+
42
+ it("darkens a mid-tone hex only when no text color reaches AA on it", () => {
43
+ // #777777: white is ~4.48:1 and near-black ~4.42:1 — neither
44
+ // passes, so the solid must shift until white does.
45
+ const p = derivePrimaryPalette("#777777");
46
+ expect(p).not.toBeNull();
47
+ const pal = p as NonNullable<typeof p>;
48
+ expect(pal.solid).not.toBe("#777777");
49
+ expect(pal.solidForeground).toBe("#ffffff");
50
+ expect(contrastRatio(pal.solid, pal.solidForeground)).toBeGreaterThanOrEqual(4.5);
51
+ });
52
+
53
+ it("keeps an already-AA dark brand color untouched with white text", () => {
54
+ const p = derivePrimaryPalette("#15803d");
55
+ expect(p?.solid).toBe("#15803d");
56
+ expect(p?.solidForeground).toBe("#ffffff");
57
+ });
58
+
59
+ it("pairs a light brand color with near-black text instead of mutating it", () => {
60
+ const p = derivePrimaryPalette("#facc15");
61
+ expect(p?.solid).toBe("#facc15");
62
+ expect(p?.solidForeground).toBe("#0a0a0a");
63
+ });
64
+
65
+ it("always yields an AA dark-theme pair from the lifted variant", () => {
66
+ for (const hex of ["#16a34a", "#1d4ed8", "#0a0a0a", "#facc15", "#e11d48"]) {
67
+ const p = derivePrimaryPalette(hex);
68
+ expect(p, hex).not.toBeNull();
69
+ const pal = p as NonNullable<typeof p>;
70
+ expect(contrastRatio(pal.dark, pal.darkForeground), hex).toBeGreaterThanOrEqual(4.5);
71
+ expect(contrastRatio(pal.solid, pal.solidForeground), hex).toBeGreaterThanOrEqual(4.5);
72
+ }
73
+ });
74
+
75
+ it("returns null for unparseable input", () => {
76
+ expect(derivePrimaryPalette("rebeccapurple")).toBeNull();
77
+ });
78
+
79
+ it("round-trips rgb↔hex", () => {
80
+ expect(rgbToHex(hexToRgb("#16a34a") as [number, number, number])).toBe("#16a34a");
81
+ });
82
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * WCAG contrast math for the configured brand color.
3
+ *
4
+ * BaseLayout derives every primary-* custom property from one place so
5
+ * the button background and its text can never drift apart (the old
6
+ * pipeline mixed the dark variant in CSS `color-mix` while the
7
+ * foreground stayed a build-time guess — white on `#16a34a` shipped at
8
+ * 3.3:1). All derivation happens at build time in sRGB; the emitted
9
+ * values are plain hex.
10
+ */
11
+
12
+ const INK_950 = "#0a0a0a";
13
+ const WHITE = "#ffffff";
14
+ const AA_NORMAL = 4.5;
15
+
16
+ export type Rgb = [number, number, number];
17
+
18
+ /** Parse #rgb / #rrggbb (also tolerates #rrggbbaa, alpha ignored). */
19
+ export function hexToRgb(hex: string): Rgb | null {
20
+ const raw = hex.replace("#", "");
21
+ const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw;
22
+ if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(full)) return null;
23
+ const int = Number.parseInt(full.slice(0, 6), 16);
24
+ return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
25
+ }
26
+
27
+ export function rgbToHex([r, g, b]: Rgb): string {
28
+ const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
29
+ return `#${[r, g, b].map((v) => clamp(v).toString(16).padStart(2, "0")).join("")}`;
30
+ }
31
+
32
+ /** WCAG relative luminance (sRGB linearization, not the quick luma). */
33
+ export function relativeLuminance([r, g, b]: Rgb): number {
34
+ const channel = (v: number) => {
35
+ const s = v / 255;
36
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
37
+ };
38
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
39
+ }
40
+
41
+ /** WCAG contrast ratio between two hex colors (1..21). */
42
+ export function contrastRatio(hexA: string, hexB: string): number {
43
+ const a = hexToRgb(hexA);
44
+ const b = hexToRgb(hexB);
45
+ if (!a || !b) return 1;
46
+ const la = relativeLuminance(a);
47
+ const lb = relativeLuminance(b);
48
+ const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
49
+ return (hi + 0.05) / (lo + 0.05);
50
+ }
51
+
52
+ function mix(color: Rgb, toward: Rgb, amount: number): Rgb {
53
+ return [
54
+ color[0] + (toward[0] - color[0]) * amount,
55
+ color[1] + (toward[1] - color[1]) * amount,
56
+ color[2] + (toward[2] - color[2]) * amount,
57
+ ];
58
+ }
59
+
60
+ /**
61
+ * Shift `hex` toward `toward` in small steps until `text` reaches AA
62
+ * contrast on it. Returns the first passing color (or the endpoint —
63
+ * black/white always pass against each other's text color).
64
+ */
65
+ function adjustUntilAA(hex: string, toward: string, text: string): string {
66
+ const start = hexToRgb(hex);
67
+ const end = hexToRgb(toward);
68
+ if (!start || !end) return hex;
69
+ for (let step = 0; step <= 100; step += 2) {
70
+ const candidate = rgbToHex(mix(start, end, step / 100));
71
+ if (contrastRatio(candidate, text) >= AA_NORMAL) return candidate;
72
+ }
73
+ return toward;
74
+ }
75
+
76
+ export interface PrimaryPalette {
77
+ /** Light-theme solid fill (AA-adjusted when the raw hex can't carry text). */
78
+ solid: string;
79
+ /** Text on `solid`. */
80
+ solidForeground: string;
81
+ /** Dark-theme lifted fill. */
82
+ dark: string;
83
+ /** Text on `dark`. */
84
+ darkForeground: string;
85
+ }
86
+
87
+ /**
88
+ * Derive the full primary palette from the configured brand hex.
89
+ *
90
+ * Light: keep the raw hex when white or near-black text reaches AA on
91
+ * it; otherwise darken toward black until white passes (`#16a34a` →
92
+ * a green-700-family solid).
93
+ * Dark: lift toward white (mirroring the old `color-mix` 72/28 split),
94
+ * then keep lifting until near-black text passes AA.
95
+ */
96
+ export function derivePrimaryPalette(hex: string): PrimaryPalette | null {
97
+ const rgb = hexToRgb(hex);
98
+ if (!rgb) return null;
99
+
100
+ let solid = hex;
101
+ let solidForeground = WHITE;
102
+ if (contrastRatio(hex, WHITE) >= AA_NORMAL) {
103
+ solidForeground = WHITE;
104
+ } else if (contrastRatio(hex, INK_950) >= AA_NORMAL) {
105
+ solidForeground = INK_950;
106
+ } else {
107
+ solid = adjustUntilAA(hex, "#000000", WHITE);
108
+ solidForeground = WHITE;
109
+ }
110
+
111
+ const lifted = rgbToHex(mix(rgb, hexToRgb(WHITE) as Rgb, 0.28));
112
+ const dark = contrastRatio(lifted, INK_950) >= AA_NORMAL
113
+ ? lifted
114
+ : adjustUntilAA(lifted, WHITE, INK_950);
115
+
116
+ return { solid, solidForeground, dark, darkForeground: INK_950 };
117
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Tests for the markdown → safe-HTML pipeline in `directory.ts`.
3
+ *
4
+ * `directory.ts` imports `@grove/generated/*.json` at module load (build-time
5
+ * artifacts), which don't exist in the test environment. We mock those JSON
6
+ * imports so the module can be loaded for its pure functions (specifically
7
+ * `renderMarkdownToSafeHtml`).
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ vi.mock('@grove/generated/records.full.json', () => ({ default: { records: [] } }));
12
+ vi.mock('@grove/generated/records.index.json', () => ({ default: { records: [] } }));
13
+ vi.mock('@grove/generated/site-config.json', () => ({ default: {} }));
14
+
15
+ const { renderMarkdownToSafeHtml } = await import('./directory.js');
16
+
17
+ describe('renderMarkdownToSafeHtml', () => {
18
+ it('renders basic paragraphs', () => {
19
+ const html = renderMarkdownToSafeHtml('Hello, **world**.');
20
+ expect(html).toContain('<strong>world</strong>');
21
+ });
22
+
23
+ it('renders headings with stable ids', () => {
24
+ const html = renderMarkdownToSafeHtml('## My Section');
25
+ expect(html).toMatch(/<h2[^>]*id="my-section"[^>]*>My Section<\/h2>/);
26
+ });
27
+
28
+ it('deduplicates repeated heading ids', () => {
29
+ const html = renderMarkdownToSafeHtml('## Examples\n\n## Examples\n');
30
+ const ids = [...html.matchAll(/id="(examples(?:-\d+)?)"/g)].map((m) => m[1]);
31
+ expect(ids).toEqual(['examples', 'examples-2']);
32
+ });
33
+
34
+ it('strips script tags from the body', () => {
35
+ const html = renderMarkdownToSafeHtml(
36
+ 'Some text.\n\n<script>alert("xss")</script>\n\nMore text.',
37
+ );
38
+ expect(html).not.toContain('<script');
39
+ expect(html).not.toContain('alert(');
40
+ });
41
+
42
+ it('strips inline event handler attributes from links', () => {
43
+ const html = renderMarkdownToSafeHtml('[click](https://example.com "onclick=alert(1)")');
44
+ // The sanitizer's allowlist is `["href", "title", "rel", "target"]` for
45
+ // `<a>` tags — `onclick` is never an attribute. (The substring `onclick=`
46
+ // may appear inside a `title="…"` tooltip, but that's plain text.)
47
+ expect(html).not.toMatch(/<a[^>]*\sonclick\s*=/i);
48
+ expect(html).toContain('href="https://example.com"');
49
+ });
50
+
51
+ it('forces noopener noreferrer on links', () => {
52
+ const html = renderMarkdownToSafeHtml('[link](https://example.com)');
53
+ expect(html).toContain('rel="noopener noreferrer"');
54
+ expect(html).toContain('target="_blank"');
55
+ });
56
+
57
+ it('rejects javascript: URLs', () => {
58
+ const html = renderMarkdownToSafeHtml('[evil](javascript:alert(1))');
59
+ expect(html).not.toContain('javascript:');
60
+ expect(html).not.toMatch(/<a[^>]*href/);
61
+ });
62
+
63
+ it('forces img loading=lazy decoding=async', () => {
64
+ const html = renderMarkdownToSafeHtml('![alt](https://example.com/img.png)');
65
+ expect(html).toMatch(/loading="lazy"/);
66
+ expect(html).toMatch(/decoding="async"/);
67
+ });
68
+
69
+ it('strips style attributes from disallowed tags', () => {
70
+ const html = renderMarkdownToSafeHtml(
71
+ '<a href="https://example.com" style="color:red">link</a>',
72
+ );
73
+ expect(html).not.toMatch(/<a[^>]*style/);
74
+ });
75
+
76
+ it('renders GFM tables inside the table-wrap div', () => {
77
+ const md = `
78
+ | col1 | col2 |
79
+ | ---- | ---- |
80
+ | a | b |
81
+ `;
82
+ const html = renderMarkdownToSafeHtml(md);
83
+ expect(html).toContain('grove-prose-table-wrap');
84
+ expect(html).toContain('<table>');
85
+ expect(html).toContain('<thead>');
86
+ expect(html).toContain('<tbody>');
87
+ });
88
+
89
+ it('renders task-list checkboxes as disabled', () => {
90
+ const md = '- [x] done\n- [ ] todo\n';
91
+ const html = renderMarkdownToSafeHtml(md);
92
+ expect(html).toContain('type="checkbox"');
93
+ expect(html).toContain('disabled');
94
+ });
95
+
96
+ it('accepts a narrower page allowlist', () => {
97
+ const html = renderMarkdownToSafeHtml('<details><summary>x</summary>y</details>', {
98
+ allowlist: [
99
+ 'h1',
100
+ 'h2',
101
+ 'h3',
102
+ 'h4',
103
+ 'p',
104
+ 'br',
105
+ 'hr',
106
+ 'ul',
107
+ 'ol',
108
+ 'li',
109
+ 'strong',
110
+ 'em',
111
+ 'b',
112
+ 'i',
113
+ 'u',
114
+ 's',
115
+ 'del',
116
+ 'a',
117
+ 'code',
118
+ 'pre',
119
+ 'blockquote',
120
+ 'img',
121
+ ],
122
+ });
123
+ expect(html).not.toContain('<details');
124
+ });
125
+
126
+ it('strips raw iframe tags', () => {
127
+ const html = renderMarkdownToSafeHtml(
128
+ 'Text.\n\n<iframe src="https://evil.example"></iframe>\n\nMore.',
129
+ );
130
+ expect(html).not.toContain('<iframe');
131
+ expect(html).not.toContain('evil.example');
132
+ });
133
+
134
+ it('strips raw svg tags (including script-bearing svgs)', () => {
135
+ const html = renderMarkdownToSafeHtml(
136
+ 'Text.\n\n<svg><script>alert("xss")</script></svg>\n\nMore.',
137
+ );
138
+ expect(html).not.toContain('<svg');
139
+ expect(html).not.toContain('<script');
140
+ });
141
+
142
+ it('strips raw object and embed tags', () => {
143
+ const html = renderMarkdownToSafeHtml(
144
+ '<object data="https://evil.example/x.swf"></object>\n<embed src="x">',
145
+ );
146
+ expect(html).not.toContain('<object');
147
+ expect(html).not.toContain('<embed');
148
+ });
149
+
150
+ it('filters malicious CSS from <span style> (closes the url(javascript:) bypass)', () => {
151
+ const html = renderMarkdownToSafeHtml(
152
+ '<span style="background:url(javascript:alert(1));color:red">x</span>',
153
+ );
154
+ expect(html).not.toContain('javascript:');
155
+ // The color: declaration is also stripped because it doesn't pass
156
+ // the allowStyles regex (no semicolon-terminated hex / rgb / hsl).
157
+ expect(html).not.toMatch(/<span[^>]*style=/);
158
+ });
159
+
160
+ it('preserves Shiki-emitted CSS variable declarations on <span>', () => {
161
+ const html = renderMarkdownToSafeHtml(
162
+ '<span style="--shiki-light:#fff;--shiki-dark:#000;color:#fff">x</span>',
163
+ );
164
+ expect(html).toMatch(/--shiki-light/);
165
+ expect(html).toMatch(/--shiki-dark/);
166
+ });
167
+
168
+ it('rejects data: URLs on <a> but allows them on <img>', () => {
169
+ const html = renderMarkdownToSafeHtml(
170
+ '[evil](data:text/html,<script>alert(1)</script>)\n\n![ok](data:image/png;base64,iVBORw0KG)',
171
+ );
172
+ // The malicious <a> URL must be removed; the inline <img> data: URL
173
+ // (a base64 PNG) is allowed by the per-tag scheme rule.
174
+ expect(html).not.toMatch(/<a[^>]*href="data:/);
175
+ expect(html).toMatch(/<img[^>]*src="data:image\/png/);
176
+ });
177
+ });