@grove-dev/astro 0.5.0-next.2 → 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 +111 -82
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +68 -20
  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 +140 -63
  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 +11 -0
  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 +170 -42
  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 -324
  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,6 +18,7 @@ interface RawRecord {
18
18
  stacks?: string[];
19
19
  platforms?: string[];
20
20
  license?: string;
21
+ licenses?: string[];
21
22
  visibility?: string;
22
23
  /** GitHub stargazers count. Stored on `github.repository.stargazers_count`
23
24
  * in the full payload; read explicitly because the index payload
@@ -81,6 +82,16 @@ export function recordsToCollectionEntries(
81
82
  stack: r.stack ?? r.stacks?.[0],
82
83
  platform: r.platforms,
83
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,
84
95
  status: r.visibility,
85
96
  stars: r.stars ?? r.github?.repository?.stargazers_count,
86
97
  forks: r.forks ?? r.github?.repository?.forks_count,
@@ -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
+ });