@grove-dev/astro 0.5.4 → 0.6.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.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -1
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +8 -0
- package/dist/ui/button.d.ts.map +1 -1
- package/dist/ui/button.js +8 -0
- package/dist/ui/button.js.map +1 -1
- package/package.json +2 -2
- package/src/components/DirectoryIndexClient.astro +351 -178
- package/src/components/Hero.astro +15 -11
- package/src/components/Pagination.astro +3 -3
- package/src/components/PoweredBy.astro +60 -0
- package/src/components/ProjectCard.astro +42 -25
- package/src/components/RecordHeader.astro +14 -9
- package/src/components/RefinePanel.astro +11 -2
- package/src/components/TableOfContents.astro +11 -12
- package/src/index.ts +8 -0
- package/src/layouts/BaseLayout.astro +14 -1
- package/src/layouts/Footer.astro +18 -2
- package/src/layouts/Header.astro +1 -1
- package/src/layouts/Seo.astro +64 -38
- package/src/lib/index.ts +0 -1
- package/src/server/collections.ts +96 -67
- package/src/server/index.ts +1 -0
- package/src/server/models.ts +456 -25
- package/src/server/seo.test.ts +123 -0
- package/src/server/seo.ts +141 -0
- package/src/styles.css +23 -6
- package/src/ui/FilterDrawer.astro +25 -5
- package/src/ui/SearchField.astro +58 -2
- package/src/ui/button.test.ts +1 -1
- package/src/ui/button.ts +9 -0
- package/dist/lib/load-collections.d.ts +0 -12
- package/dist/lib/load-collections.d.ts.map +0 -1
- package/dist/lib/load-collections.js +0 -34
- package/dist/lib/load-collections.js.map +0 -1
- package/src/lib/load-collections.ts +0 -33
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
absoluteUrl,
|
|
4
|
+
breadcrumbs,
|
|
5
|
+
ogPath,
|
|
6
|
+
recordSeoDescriptor,
|
|
7
|
+
seoDescription,
|
|
8
|
+
seoTitle,
|
|
9
|
+
titleCaseFirst,
|
|
10
|
+
} from "./seo.js";
|
|
11
|
+
|
|
12
|
+
describe("seoTitle", () => {
|
|
13
|
+
it("appends the site name with the pipe separator", () => {
|
|
14
|
+
expect(seoTitle("Collections", "Open Apps")).toBe("Collections | Open Apps");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("skips the suffix when the main part already names the site", () => {
|
|
18
|
+
expect(seoTitle("Python projects on Open Apps", "Open Apps")).toBe(
|
|
19
|
+
"Python projects on Open Apps",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("skips the suffix instead of exceeding the display cap", () => {
|
|
24
|
+
const long = "A very long record name — with an equally long descriptor attached";
|
|
25
|
+
expect(seoTitle(long, "Open Apps")).toBe(long);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("falls back to the site name for an empty main part", () => {
|
|
29
|
+
expect(seoTitle(" ", "Open Apps")).toBe("Open Apps");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns the main part unchanged when no site name is given", () => {
|
|
33
|
+
expect(seoTitle("Collections", "")).toBe("Collections");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("seoDescription", () => {
|
|
38
|
+
it("prefers the raw text and collapses whitespace", () => {
|
|
39
|
+
expect(seoDescription("A spaced\n\ntext.", "fallback")).toBe("A spaced text.");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("uses the fallback when raw is empty", () => {
|
|
43
|
+
expect(seoDescription(undefined, "The fallback.")).toBe("The fallback.");
|
|
44
|
+
expect(seoDescription(" ", "The fallback.")).toBe("The fallback.");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("truncates on a word boundary near 160 chars with an ellipsis", () => {
|
|
48
|
+
const long = "word ".repeat(60).trim();
|
|
49
|
+
const out = seoDescription(long, "");
|
|
50
|
+
expect(out.length).toBeLessThanOrEqual(160);
|
|
51
|
+
expect(out.endsWith("…")).toBe(true);
|
|
52
|
+
expect(out).not.toMatch(/\swor…$/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("recordSeoDescriptor", () => {
|
|
57
|
+
it("uses the summary's first clause when short enough", () => {
|
|
58
|
+
expect(
|
|
59
|
+
recordSeoDescriptor({
|
|
60
|
+
summary: "Self-hosted photo backup. Works offline and syncs later.",
|
|
61
|
+
singular: "app",
|
|
62
|
+
}),
|
|
63
|
+
).toBe("Self-hosted photo backup");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("falls back to the category phrase when the summary is too long", () => {
|
|
67
|
+
expect(
|
|
68
|
+
recordSeoDescriptor({
|
|
69
|
+
summary:
|
|
70
|
+
"A very long first clause that keeps going and going far past the cap",
|
|
71
|
+
categoryLabel: "Photos",
|
|
72
|
+
singular: "app",
|
|
73
|
+
}),
|
|
74
|
+
).toBe("Open-source Photos app");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("omits the category when it is missing or uncategorized", () => {
|
|
78
|
+
expect(recordSeoDescriptor({ singular: "project" })).toBe("Open-source project");
|
|
79
|
+
expect(
|
|
80
|
+
recordSeoDescriptor({ categoryLabel: "Uncategorized", singular: "project" }),
|
|
81
|
+
).toBe("Open-source project");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("titleCaseFirst", () => {
|
|
86
|
+
it("capitalizes only the first character", () => {
|
|
87
|
+
expect(titleCaseFirst("projects")).toBe("Projects");
|
|
88
|
+
expect(titleCaseFirst("")).toBe("");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("ogPath", () => {
|
|
93
|
+
it("falls back to the static SVG when no generated image exists", () => {
|
|
94
|
+
// Point at a directory with no public/og at all.
|
|
95
|
+
expect(ogPath("record", "nothing-here", "/nonexistent-cwd")).toBe("/og-image.svg");
|
|
96
|
+
expect(ogPath("home", undefined, "/nonexistent-cwd")).toBe("/og-image.svg");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("absoluteUrl", () => {
|
|
101
|
+
it("joins trailing-slash-safely", () => {
|
|
102
|
+
expect(absoluteUrl("https://a.com/", "/x/")).toBe("https://a.com/x/");
|
|
103
|
+
expect(absoluteUrl("https://a.com", "x/")).toBe("https://a.com/x/");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("breadcrumbs", () => {
|
|
108
|
+
it("builds an absolute BreadcrumbList with trailing slashes", () => {
|
|
109
|
+
const node = breadcrumbs("https://a.com", [
|
|
110
|
+
{ path: "", name: "Home" },
|
|
111
|
+
{ path: "categories", name: "Categories" },
|
|
112
|
+
{ path: "categories/photos/", name: "Photos" },
|
|
113
|
+
]);
|
|
114
|
+
expect(node["@type"]).toBe("BreadcrumbList");
|
|
115
|
+
const items = node.itemListElement as Array<{ position: number; name: string; item: string }>;
|
|
116
|
+
expect(items.map((i) => i.item)).toEqual([
|
|
117
|
+
"https://a.com/",
|
|
118
|
+
"https://a.com/categories/",
|
|
119
|
+
"https://a.com/categories/photos/",
|
|
120
|
+
]);
|
|
121
|
+
expect(items[2]).toMatchObject({ position: 3, name: "Photos" });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO primitives — the ONE place page titles, meta descriptions, OG
|
|
3
|
+
* image paths, and breadcrumb JSON-LD are shaped.
|
|
4
|
+
*
|
|
5
|
+
* Every page model funnels through `seoTitle` / `seoDescription`, so
|
|
6
|
+
* the separator (` | ` before the site name, ` — ` inside the main
|
|
7
|
+
* part), the length caps, and the truncation rules can never drift
|
|
8
|
+
* between pages. The pure helpers take every input as an argument —
|
|
9
|
+
* no generated-data imports — so they are unit-testable in isolation.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { breadcrumbSchema, type JsonLdNode } from "@grove-dev/core";
|
|
14
|
+
|
|
15
|
+
/** Everything a page passes to BaseLayout for its `<head>`. */
|
|
16
|
+
export interface PageSeo {
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
/** Root-relative OG image (e.g. "/og/records/langchain.png"). */
|
|
20
|
+
image?: string;
|
|
21
|
+
imageAlt?: string;
|
|
22
|
+
jsonLd?: Record<string, unknown>[];
|
|
23
|
+
noindex?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Google truncates titles around 60 chars; suffixing past ~65 only
|
|
27
|
+
* buys an ellipsis, so the suffix is skipped instead of clipped. */
|
|
28
|
+
const TITLE_SUFFIX_MAX = 65;
|
|
29
|
+
/** Meta descriptions display up to ~160 chars in search results. */
|
|
30
|
+
const DESCRIPTION_MAX = 160;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compose `<main> | <siteName>`. The suffix is dropped when the main
|
|
34
|
+
* part already names the site (taxonomy titles end "... on {Site}")
|
|
35
|
+
* or when appending it would push the title past the display cap —
|
|
36
|
+
* a truncated site name is worse than none.
|
|
37
|
+
*/
|
|
38
|
+
export function seoTitle(main: string, siteName: string): string {
|
|
39
|
+
const cleaned = main.trim();
|
|
40
|
+
if (!cleaned) return siteName;
|
|
41
|
+
if (!siteName || cleaned.includes(siteName)) return cleaned;
|
|
42
|
+
const suffixed = `${cleaned} | ${siteName}`;
|
|
43
|
+
return suffixed.length <= TITLE_SUFFIX_MAX ? suffixed : cleaned;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a meta description: prefer `raw`, fall back, collapse
|
|
48
|
+
* whitespace, and truncate on a word boundary near 160 chars.
|
|
49
|
+
*/
|
|
50
|
+
export function seoDescription(raw: string | undefined, fallback: string): string {
|
|
51
|
+
const source = (raw ?? "").replace(/\s+/g, " ").trim() || fallback.replace(/\s+/g, " ").trim();
|
|
52
|
+
if (source.length <= DESCRIPTION_MAX) return source;
|
|
53
|
+
const cut = source.slice(0, DESCRIPTION_MAX - 1);
|
|
54
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
55
|
+
return `${(lastSpace > 80 ? cut.slice(0, lastSpace) : cut).replace(/[,;:\s]+$/, "")}…`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Capitalize the first character (for titles like "Browse Projects"). */
|
|
59
|
+
export function titleCaseFirst(value: string): string {
|
|
60
|
+
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Short record descriptor for `<Name> — <descriptor> | <Site>` titles.
|
|
65
|
+
* Prefers the curator-written summary's first clause when it is short
|
|
66
|
+
* enough to fit a title; otherwise falls back to a generated
|
|
67
|
+
* "Open-source <category> <singular>" phrase.
|
|
68
|
+
*/
|
|
69
|
+
export function recordSeoDescriptor(input: {
|
|
70
|
+
summary?: string;
|
|
71
|
+
categoryLabel?: string;
|
|
72
|
+
singular: string;
|
|
73
|
+
}): string {
|
|
74
|
+
const clause = (input.summary ?? "")
|
|
75
|
+
.split(/[.!?\n]|\s[—–]\s/)[0]
|
|
76
|
+
?.replace(/\s+/g, " ")
|
|
77
|
+
.trim();
|
|
78
|
+
if (clause && clause.length > 0 && clause.length <= 45) return clause;
|
|
79
|
+
const category = input.categoryLabel?.trim();
|
|
80
|
+
return category && category.toLowerCase() !== "uncategorized"
|
|
81
|
+
? `Open-source ${category} ${input.singular}`
|
|
82
|
+
: `Open-source ${input.singular}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type OgKind =
|
|
86
|
+
| "home"
|
|
87
|
+
| "default"
|
|
88
|
+
| "record"
|
|
89
|
+
| "collection"
|
|
90
|
+
| "category"
|
|
91
|
+
| "stack"
|
|
92
|
+
| "license";
|
|
93
|
+
|
|
94
|
+
const OG_DIR: Record<OgKind, string> = {
|
|
95
|
+
home: "home.png",
|
|
96
|
+
default: "default.png",
|
|
97
|
+
record: "records",
|
|
98
|
+
collection: "collections",
|
|
99
|
+
category: "categories",
|
|
100
|
+
stack: "stacks",
|
|
101
|
+
license: "licenses",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the OG image URL for a page. Returns the generated
|
|
106
|
+
* `/og/...png` when the build pipeline produced it, otherwise the
|
|
107
|
+
* site-wide `/og-image.svg` — so a failed or skipped OG build
|
|
108
|
+
* degrades to the static default instead of a 404.
|
|
109
|
+
*/
|
|
110
|
+
export function ogPath(kind: OgKind, slug?: string, cwd = process.cwd()): string {
|
|
111
|
+
const rel =
|
|
112
|
+
kind === "home" || kind === "default"
|
|
113
|
+
? `og/${OG_DIR[kind]}`
|
|
114
|
+
: slug
|
|
115
|
+
? `og/${OG_DIR[kind]}/${slug}.png`
|
|
116
|
+
: "og/default.png";
|
|
117
|
+
if (existsSync(resolve(cwd, "public", rel))) return `/${rel}`;
|
|
118
|
+
// Per-page image missing — try the generated default before the SVG.
|
|
119
|
+
if (rel !== "og/default.png" && existsSync(resolve(cwd, "public", "og/default.png"))) {
|
|
120
|
+
return "/og/default.png";
|
|
121
|
+
}
|
|
122
|
+
return "/og-image.svg";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Join a site-relative path onto the site URL (trailing-slash safe). */
|
|
126
|
+
export function absoluteUrl(siteUrl: string, path: string): string {
|
|
127
|
+
return `${siteUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* BreadcrumbList JSON-LD from site-relative paths. Wraps core's
|
|
132
|
+
* `breadcrumbSchema` so pages pass paths, not absolute URLs.
|
|
133
|
+
*/
|
|
134
|
+
export function breadcrumbs(
|
|
135
|
+
siteUrl: string,
|
|
136
|
+
crumbs: Array<{ path: string; name: string }>,
|
|
137
|
+
): JsonLdNode {
|
|
138
|
+
return breadcrumbSchema(
|
|
139
|
+
crumbs.map((c) => ({ url: absoluteUrl(siteUrl, c.path.endsWith("/") || c.path === "" ? c.path : `${c.path}/`), name: c.name })),
|
|
140
|
+
);
|
|
141
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -148,8 +148,15 @@
|
|
|
148
148
|
* grove.config.ts: BaseLayout emits `--grove-container` on <html>
|
|
149
149
|
* and the token falls back to the package default when unset. */
|
|
150
150
|
--container-container: var(--grove-container, 1160px);
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
/* `wide` is an opt-in escape hatch, not a second site width. It is
|
|
152
|
+
clamped to the configured container so a wide section can never
|
|
153
|
+
overhang the header, footer, and the rest of the page — every
|
|
154
|
+
shell and page template uses `max-w-container`. */
|
|
155
|
+
--container-wide: min(var(--grove-container, 1400px), 1400px);
|
|
156
|
+
/* `narrow` is the reading measure for prose pages (about, 404,
|
|
157
|
+
empty). Clamped for the same reason — a site narrower than 720px
|
|
158
|
+
should not have its prose pages spill past its own frame. */
|
|
159
|
+
--container-narrow: min(var(--grove-container, 720px), 720px);
|
|
153
160
|
|
|
154
161
|
/* ── Border radius scale ──────────────────────────────────────────
|
|
155
162
|
* Driven by `theme.radius` ("none" | "soft" | "round") the same
|
|
@@ -943,15 +950,25 @@
|
|
|
943
950
|
display: block;
|
|
944
951
|
}
|
|
945
952
|
}
|
|
946
|
-
|
|
953
|
+
/* Active item: text-color + a left-rail accent on the link's
|
|
954
|
+
* leading edge. A full pill background reads as a button, not a
|
|
955
|
+
* nav indicator, so it is reserved for hover; the rail stays put
|
|
956
|
+
* while the visitor moves between sections. */
|
|
957
|
+
.grove-toc-item > .grove-toc-link {
|
|
958
|
+
position: relative;
|
|
959
|
+
}
|
|
960
|
+
.grove-toc-link {
|
|
961
|
+
border-left: 2px solid transparent;
|
|
962
|
+
}
|
|
963
|
+
.grove-toc-item--h3 > .grove-toc-link {
|
|
964
|
+
border-left-style: dashed;
|
|
965
|
+
}
|
|
947
966
|
.grove-toc-link[data-toc-active] {
|
|
948
|
-
background-color: var(--color-ink-100, #f0f0f0);
|
|
949
967
|
color: var(--color-ink-900, #1a1a1a);
|
|
968
|
+
border-left-color: currentColor;
|
|
950
969
|
font-weight: 600;
|
|
951
|
-
border-radius: 0.375rem;
|
|
952
970
|
}
|
|
953
971
|
.dark .grove-toc-link[data-toc-active] {
|
|
954
|
-
background-color: var(--color-ink-800, #2a2a2a);
|
|
955
972
|
color: var(--color-ink-100, #f0f0f0);
|
|
956
973
|
}
|
|
957
974
|
|
|
@@ -19,10 +19,17 @@ interface Props {
|
|
|
19
19
|
label?: string;
|
|
20
20
|
/** Count of active filters shown on the trigger. */
|
|
21
21
|
activeCount?: number;
|
|
22
|
+
/** URL that clears every filter — offered inside the sheet. */
|
|
23
|
+
clearHref?: string;
|
|
22
24
|
class?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
label = "Filters",
|
|
29
|
+
activeCount = 0,
|
|
30
|
+
clearHref,
|
|
31
|
+
class: className = "",
|
|
32
|
+
} = Astro.props;
|
|
26
33
|
---
|
|
27
34
|
|
|
28
35
|
<div class={`grove-filter-drawer ${className}`} data-grove-filter-drawer>
|
|
@@ -58,11 +65,24 @@ const { label = "Filters", activeCount = 0, class: className = "" } = Astro.prop
|
|
|
58
65
|
class="m-0 mt-auto w-full max-w-none rounded-t-[calc(var(--radius)+0.5rem)] border-0 bg-surface-overlay p-0 text-foreground backdrop:bg-black/40 md:hidden"
|
|
59
66
|
aria-label={label}
|
|
60
67
|
>
|
|
61
|
-
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
|
68
|
+
<div class="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
|
|
62
69
|
<p class="m-0 text-sm font-semibold">{label}</p>
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
<div class="flex items-center gap-2">
|
|
71
|
+
{clearHref && (
|
|
72
|
+
/* Clearing from inside the sheet is the only way out of a
|
|
73
|
+
stack of filters on mobile — the chips row is behind it. */
|
|
74
|
+
<a
|
|
75
|
+
href={clearHref}
|
|
76
|
+
data-drawer-clear
|
|
77
|
+
class:list={[buttonClass("ghost", "sm"), activeCount === 0 && "hidden"]}
|
|
78
|
+
>
|
|
79
|
+
Clear all
|
|
80
|
+
</a>
|
|
81
|
+
)}
|
|
82
|
+
<button type="button" data-drawer-close class={buttonClass("ghost", "sm")}>
|
|
83
|
+
Done
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
66
86
|
</div>
|
|
67
87
|
<div data-drawer-body class="max-h-[70vh] overflow-y-auto p-4"></div>
|
|
68
88
|
</dialog>
|
package/src/ui/SearchField.astro
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* is display copy, not the accessible name. Pass the derived
|
|
7
7
|
* placeholder from `getDirectoryIndexModel().searchPlaceholder` so it
|
|
8
8
|
* only names ENABLED facets.
|
|
9
|
+
*
|
|
10
|
+
* `shortcut` renders the `/` hint and wires the global key handler.
|
|
11
|
+
* The hint is hidden once the field has focus — it is an invitation,
|
|
12
|
+
* not a persistent label — and on coarse pointers, where there is no
|
|
13
|
+
* key to press.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
interface Props {
|
|
@@ -14,6 +19,8 @@ interface Props {
|
|
|
14
19
|
id?: string;
|
|
15
20
|
value?: string;
|
|
16
21
|
placeholder?: string;
|
|
22
|
+
/** Show the `/` hint and focus this field when `/` is pressed. */
|
|
23
|
+
shortcut?: boolean;
|
|
17
24
|
class?: string;
|
|
18
25
|
}
|
|
19
26
|
|
|
@@ -23,11 +30,12 @@ const {
|
|
|
23
30
|
id = "search-input",
|
|
24
31
|
value = "",
|
|
25
32
|
placeholder,
|
|
33
|
+
shortcut = false,
|
|
26
34
|
class: className = "",
|
|
27
35
|
} = Astro.props;
|
|
28
36
|
---
|
|
29
37
|
|
|
30
|
-
<label class={`relative block ${className}`}>
|
|
38
|
+
<label class={`grove-search relative block ${className}`}>
|
|
31
39
|
<span class="sr-only">{label}</span>
|
|
32
40
|
<svg
|
|
33
41
|
viewBox="0 0 16 16"
|
|
@@ -45,6 +53,54 @@ const {
|
|
|
45
53
|
name={name}
|
|
46
54
|
value={value}
|
|
47
55
|
placeholder={placeholder}
|
|
48
|
-
|
|
56
|
+
autocomplete="off"
|
|
57
|
+
data-search-input
|
|
58
|
+
class="peer h-10 w-full rounded-md border border-input bg-background py-2 pl-9 pr-10 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20 [&::-webkit-search-cancel-button]:appearance-none"
|
|
49
59
|
/>
|
|
60
|
+
{shortcut && (
|
|
61
|
+
<kbd
|
|
62
|
+
data-search-hint
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
class="pointer-events-none absolute right-2 top-1/2 hidden h-6 w-6 -translate-y-1/2 select-none items-center justify-center rounded-md border border-border bg-secondary font-mono text-[11px] leading-none text-muted-foreground peer-focus:!hidden peer-not-placeholder-shown:hidden sm:inline-flex"
|
|
65
|
+
>/</kbd
|
|
66
|
+
>
|
|
67
|
+
)}
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
data-search-clear
|
|
71
|
+
hidden={!value}
|
|
72
|
+
aria-label="Clear search"
|
|
73
|
+
class="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
|
|
74
|
+
>
|
|
75
|
+
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" fill="currentColor">
|
|
76
|
+
<path
|
|
77
|
+
d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
|
|
78
|
+
/>
|
|
79
|
+
</svg>
|
|
80
|
+
</button>
|
|
50
81
|
</label>
|
|
82
|
+
|
|
83
|
+
{shortcut && (
|
|
84
|
+
<script>
|
|
85
|
+
// `/` focuses the directory search from anywhere on the page, but
|
|
86
|
+
// never while the visitor is already typing somewhere — otherwise
|
|
87
|
+
// the shortcut eats a literal slash out of another field.
|
|
88
|
+
document.addEventListener("keydown", (event) => {
|
|
89
|
+
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
90
|
+
const active = document.activeElement as HTMLElement | null;
|
|
91
|
+
if (
|
|
92
|
+
active &&
|
|
93
|
+
(active.tagName === "INPUT" ||
|
|
94
|
+
active.tagName === "TEXTAREA" ||
|
|
95
|
+
active.tagName === "SELECT" ||
|
|
96
|
+
active.isContentEditable)
|
|
97
|
+
)
|
|
98
|
+
return;
|
|
99
|
+
const input = document.querySelector("[data-search-input]");
|
|
100
|
+
if (!(input instanceof HTMLInputElement)) return;
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
input.focus();
|
|
103
|
+
input.select();
|
|
104
|
+
});
|
|
105
|
+
</script>
|
|
106
|
+
)}
|
package/src/ui/button.test.ts
CHANGED
|
@@ -41,7 +41,7 @@ describe("button class builders", () => {
|
|
|
41
41
|
"utf8",
|
|
42
42
|
);
|
|
43
43
|
const serverPage = readFileSync(
|
|
44
|
-
resolve(import.meta.dirname, "../../../../apps/example/src/
|
|
44
|
+
resolve(import.meta.dirname, "../../../../apps/example/src/components/DirectoryBrowse.astro"),
|
|
45
45
|
"utf8",
|
|
46
46
|
);
|
|
47
47
|
expect(clientSource).toContain("chipClass()");
|
package/src/ui/button.ts
CHANGED
|
@@ -57,6 +57,15 @@ export function buttonClass(
|
|
|
57
57
|
*/
|
|
58
58
|
export const PAGINATION_EXTRA = "min-w-9 px-2 py-1.5 text-2xs";
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The dead end of a pagination row — "Previous" on page 1.
|
|
62
|
+
*
|
|
63
|
+
* Dimming to `opacity-40` put it at 3.37:1 against the dark surface,
|
|
64
|
+
* which is a contrast failure, not a disabled state. 60% still reads
|
|
65
|
+
* as unavailable and clears 4.5:1.
|
|
66
|
+
*/
|
|
67
|
+
export const PAGINATION_DISABLED = "pointer-events-none opacity-60";
|
|
68
|
+
|
|
60
69
|
/**
|
|
61
70
|
* Facet filter trigger (the "Stack: Any" dropdown buttons). Includes
|
|
62
71
|
* the `grove-filter-trigger` hook class because the client script
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { Collection } from "@grove-dev/core";
|
|
2
|
-
/**
|
|
3
|
-
* Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
|
|
4
|
-
*
|
|
5
|
-
* Returns an empty array if the directory does not exist, so callers can
|
|
6
|
-
* safely render an empty/loading state without having to guard each
|
|
7
|
-
* invocation site. Parse errors and other unexpected failures are NOT
|
|
8
|
-
* swallowed — they surface so real problems (malformed YAML, permission
|
|
9
|
-
* errors, etc.) are visible instead of silently producing empty output.
|
|
10
|
-
*/
|
|
11
|
-
export declare function loadCollections(cwd: string): Promise<Collection[]>;
|
|
12
|
-
//# sourceMappingURL=load-collections.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"load-collections.d.ts","sourceRoot":"","sources":["../../src/lib/load-collections.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAkBxE"}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
3
|
-
import { parse as parseYaml } from "yaml";
|
|
4
|
-
/**
|
|
5
|
-
* Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
|
|
6
|
-
*
|
|
7
|
-
* Returns an empty array if the directory does not exist, so callers can
|
|
8
|
-
* safely render an empty/loading state without having to guard each
|
|
9
|
-
* invocation site. Parse errors and other unexpected failures are NOT
|
|
10
|
-
* swallowed — they surface so real problems (malformed YAML, permission
|
|
11
|
-
* errors, etc.) are visible instead of silently producing empty output.
|
|
12
|
-
*/
|
|
13
|
-
export async function loadCollections(cwd) {
|
|
14
|
-
const dir = resolve(cwd, "data/collections");
|
|
15
|
-
let files;
|
|
16
|
-
try {
|
|
17
|
-
files = await readdir(dir);
|
|
18
|
-
}
|
|
19
|
-
catch (err) {
|
|
20
|
-
if (err.code === "ENOENT")
|
|
21
|
-
return [];
|
|
22
|
-
throw err;
|
|
23
|
-
}
|
|
24
|
-
const out = [];
|
|
25
|
-
for (const f of files.filter((f) => f.endsWith(".yml"))) {
|
|
26
|
-
const raw = parseYaml(await readFile(join(dir, f), "utf8"));
|
|
27
|
-
if (!raw || typeof raw !== "object") {
|
|
28
|
-
throw new Error(`Invalid collection YAML: ${f}`);
|
|
29
|
-
}
|
|
30
|
-
out.push(raw);
|
|
31
|
-
}
|
|
32
|
-
return out;
|
|
33
|
-
}
|
|
34
|
-
//# sourceMappingURL=load-collections.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"load-collections.js","sourceRoot":"","sources":["../../src/lib/load-collections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,KAAK,IAAI,SAAS,EAAE,MAAM,MAAM,CAAC;AAG1C;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAW;IAC/C,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC;IAC7C,IAAI,KAAe,CAAC;IACpB,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;QAChE,MAAM,GAAG,CAAC;IACZ,CAAC;IACD,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QACxD,MAAM,GAAG,GAAG,SAAS,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;QAC5D,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;YACpC,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,EAAE,CAAC,CAAC;QACnD,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,GAAiB,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
3
|
-
import { parse as parseYaml } from "yaml";
|
|
4
|
-
import type { Collection } from "@grove-dev/core";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
|
|
8
|
-
*
|
|
9
|
-
* Returns an empty array if the directory does not exist, so callers can
|
|
10
|
-
* safely render an empty/loading state without having to guard each
|
|
11
|
-
* invocation site. Parse errors and other unexpected failures are NOT
|
|
12
|
-
* swallowed — they surface so real problems (malformed YAML, permission
|
|
13
|
-
* errors, etc.) are visible instead of silently producing empty output.
|
|
14
|
-
*/
|
|
15
|
-
export async function loadCollections(cwd: string): Promise<Collection[]> {
|
|
16
|
-
const dir = resolve(cwd, "data/collections");
|
|
17
|
-
let files: string[];
|
|
18
|
-
try {
|
|
19
|
-
files = await readdir(dir);
|
|
20
|
-
} catch (err) {
|
|
21
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
22
|
-
throw err;
|
|
23
|
-
}
|
|
24
|
-
const out: Collection[] = [];
|
|
25
|
-
for (const f of files.filter((f) => f.endsWith(".yml"))) {
|
|
26
|
-
const raw = parseYaml(await readFile(join(dir, f), "utf8"));
|
|
27
|
-
if (!raw || typeof raw !== "object") {
|
|
28
|
-
throw new Error(`Invalid collection YAML: ${f}`);
|
|
29
|
-
}
|
|
30
|
-
out.push(raw as Collection);
|
|
31
|
-
}
|
|
32
|
-
return out;
|
|
33
|
-
}
|