@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.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -2
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -2
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +45 -0
- package/dist/ui/button.d.ts.map +1 -0
- package/dist/ui/button.js +82 -0
- package/dist/ui/button.js.map +1 -0
- package/package.json +8 -5
- package/src/components/CardGrid.astro +41 -0
- package/src/components/CardIcon.astro +67 -0
- package/src/components/CategoryGrid.astro +20 -5
- package/src/components/CollectionCard.astro +56 -0
- package/src/components/CollectionIndex.astro +21 -26
- package/src/components/CollectionPage.astro +36 -13
- package/src/components/CollectionRow.astro +62 -192
- package/src/components/CollectionTeaser.astro +4 -0
- package/src/components/ContributorsGrid.astro +4 -1
- package/src/components/DirectoryIndexClient.astro +127 -30
- package/src/components/EditorialSummary.astro +6 -5
- package/src/components/FilterGroupMenu.astro +24 -10
- package/src/components/FilterOptions.astro +6 -1
- package/src/components/FinalCta.astro +5 -3
- package/src/components/Hero.astro +85 -139
- package/src/components/IndexRow.astro +36 -69
- package/src/components/LanguageBreakdown.astro +5 -1
- package/src/components/OriginalCollection.astro +3 -2
- package/src/components/Pagination.astro +7 -20
- package/src/components/ProjectCard.astro +345 -0
- package/src/components/RecordHeader.astro +111 -82
- package/src/components/RecordSection.astro +14 -10
- package/src/components/RecordSidebar.astro +68 -20
- package/src/components/RefinePanel.astro +41 -62
- package/src/components/SmartLensTabs.astro +3 -7
- package/src/components/StackGrid.astro +26 -6
- package/src/components/SubmissionClient.astro +140 -63
- package/src/components/TableOfContents.astro +31 -5
- package/src/components/WhyThisExists.astro +1 -1
- package/src/index.ts +2 -2
- package/src/layouts/BaseLayout.astro +79 -9
- package/src/layouts/Header.astro +5 -4
- package/src/layouts/SectionHeader.astro +3 -2
- package/src/layouts/Seo.astro +28 -17
- package/src/layouts/ThemeToggle.astro +23 -6
- package/src/lib/index.ts +0 -2
- package/src/server/collections.ts +11 -0
- package/src/server/contrast.test.ts +82 -0
- package/src/server/contrast.ts +117 -0
- package/src/server/directory.test.ts +177 -0
- package/src/server/directory.ts +328 -206
- package/src/server/github-repo.test.ts +88 -0
- package/src/server/github-repo.ts +104 -0
- package/src/server/index.ts +4 -3
- package/src/server/models-home.test.ts +101 -0
- package/src/server/models.test.ts +135 -0
- package/src/server/models.ts +170 -42
- package/src/styles.css +128 -16
- package/dist/lib/scores.d.ts +0 -2
- package/dist/lib/scores.d.ts.map +0 -1
- package/dist/lib/scores.js +0 -2
- package/dist/lib/scores.js.map +0 -1
- package/src/components/CurationGrid.astro +0 -41
- package/src/components/DecisionRow.astro +0 -89
- package/src/components/ExploreByCategory.astro +0 -67
- package/src/components/ExploreByStack.astro +0 -78
- package/src/components/GroveDocumentHead.astro +0 -28
- package/src/components/ItemCard.astro +0 -324
- package/src/components/MinimalAbout.astro +0 -82
- package/src/components/ScoreBars.astro +0 -102
- package/src/lib/scores.ts +0 -1
package/src/layouts/Seo.astro
CHANGED
|
@@ -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?:
|
|
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 =
|
|
46
|
-
image =
|
|
47
|
-
type =
|
|
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
|
-
|
|
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() +
|
|
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
|
-
|
|
68
|
-
|
|
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:
|
|
82
|
+
url: siteUrl.toString().replace(/\/$/, ''),
|
|
83
|
+
inLanguage: 'en',
|
|
73
84
|
potentialAction: {
|
|
74
|
-
|
|
75
|
-
target: `${siteUrl.toString().replace(/\/$/,
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
92
|
+
'@context': 'https://schema.org',
|
|
93
|
+
'@type': 'Organization',
|
|
83
94
|
name: siteConfig.name,
|
|
84
|
-
url: siteUrl.toString().replace(/\/$/,
|
|
85
|
-
logo: new URL(
|
|
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="
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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('');
|
|
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',
|
|
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
|
+
});
|