@grove-dev/astro 0.6.1 → 0.8.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 (111) hide show
  1. package/README.md +77 -5
  2. package/dist/index.d.ts +3 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +22 -33
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/display.d.ts +1 -1
  7. package/dist/lib/display.js +1 -1
  8. package/dist/lib/format.d.ts +1 -1
  9. package/dist/lib/format.js +1 -1
  10. package/dist/lib/icon-kinds.d.ts +1 -1
  11. package/dist/lib/icon-kinds.js +43 -43
  12. package/dist/lib/icon-registry.d.ts +2 -2
  13. package/dist/lib/icon-registry.js +23 -23
  14. package/dist/lib/index.d.ts +9 -9
  15. package/dist/lib/index.d.ts.map +1 -1
  16. package/dist/lib/index.js +9 -9
  17. package/dist/lib/index.js.map +1 -1
  18. package/dist/lib/lenses.d.ts +1 -1
  19. package/dist/lib/lenses.js +1 -1
  20. package/dist/lib/packaged-icons.d.ts +1 -1
  21. package/dist/lib/packaged-icons.js +4 -4
  22. package/dist/lib/repo.d.ts +1 -1
  23. package/dist/lib/repo.js +1 -1
  24. package/dist/lib/search.d.ts +1 -1
  25. package/dist/lib/search.js +1 -1
  26. package/dist/lib/taxonomy-counts.d.ts +1 -1
  27. package/dist/lib/taxonomy-counts.js +1 -1
  28. package/package.json +7 -17
  29. package/src/index.ts +23 -38
  30. package/src/lib/display.ts +1 -1
  31. package/src/lib/format.ts +1 -1
  32. package/src/lib/icon-kinds.ts +44 -44
  33. package/src/lib/icon-registry.ts +25 -25
  34. package/src/lib/index.ts +9 -9
  35. package/src/lib/lenses.ts +1 -1
  36. package/src/lib/packaged-icons.ts +8 -8
  37. package/src/lib/repo.ts +1 -1
  38. package/src/lib/search.ts +1 -1
  39. package/src/lib/taxonomy-counts.ts +1 -1
  40. package/src/server/collections.ts +53 -42
  41. package/src/server/contrast.ts +14 -9
  42. package/src/server/directory.ts +22 -17
  43. package/src/server/index.ts +3 -2
  44. package/src/server/models.ts +404 -213
  45. package/src/server/seo.ts +34 -31
  46. package/src/styles.css +235 -91
  47. package/dist/layouts/index.d.ts +0 -25
  48. package/dist/layouts/index.d.ts.map +0 -1
  49. package/dist/layouts/index.js +0 -25
  50. package/dist/layouts/index.js.map +0 -1
  51. package/dist/ui/button.d.ts +0 -53
  52. package/dist/ui/button.d.ts.map +0 -1
  53. package/dist/ui/button.js +0 -90
  54. package/dist/ui/button.js.map +0 -1
  55. package/src/components/CardGrid.astro +0 -41
  56. package/src/components/CardIcon.astro +0 -67
  57. package/src/components/CategoryGrid.astro +0 -114
  58. package/src/components/CollectionCard.astro +0 -56
  59. package/src/components/CollectionIndex.astro +0 -102
  60. package/src/components/CollectionPage.astro +0 -128
  61. package/src/components/CollectionRow.astro +0 -81
  62. package/src/components/CollectionTeaser.astro +0 -47
  63. package/src/components/ContributorsGrid.astro +0 -143
  64. package/src/components/DirectoryIndexClient.astro +0 -510
  65. package/src/components/EditorialSummary.astro +0 -58
  66. package/src/components/FilterGroupMenu.astro +0 -69
  67. package/src/components/FilterOptions.astro +0 -49
  68. package/src/components/FinalCta.astro +0 -43
  69. package/src/components/Hero.astro +0 -243
  70. package/src/components/Icon.astro +0 -191
  71. package/src/components/IndexRow.astro +0 -37
  72. package/src/components/LanguageBreakdown.astro +0 -67
  73. package/src/components/MarkdownBody.astro +0 -37
  74. package/src/components/OriginalCollection.astro +0 -117
  75. package/src/components/Pagination.astro +0 -50
  76. package/src/components/PoweredBy.astro +0 -60
  77. package/src/components/ProjectCard.astro +0 -321
  78. package/src/components/RecordHeader.astro +0 -221
  79. package/src/components/RecordSection.astro +0 -128
  80. package/src/components/RecordSidebar.astro +0 -387
  81. package/src/components/RefinePanel.astro +0 -168
  82. package/src/components/SmartLensTabs.astro +0 -81
  83. package/src/components/StackGrid.astro +0 -151
  84. package/src/components/StackPlatformChips.astro +0 -73
  85. package/src/components/SubmissionClient.astro +0 -254
  86. package/src/components/TableOfContents.astro +0 -140
  87. package/src/components/WhyThisExists.astro +0 -116
  88. package/src/layouts/BaseLayout.astro +0 -415
  89. package/src/layouts/Container.astro +0 -18
  90. package/src/layouts/Footer.astro +0 -231
  91. package/src/layouts/Header.astro +0 -214
  92. package/src/layouts/SectionHeader.astro +0 -43
  93. package/src/layouts/Seo.astro +0 -171
  94. package/src/layouts/ThemeToggle.astro +0 -118
  95. package/src/layouts/astro-shim.d.ts +0 -29
  96. package/src/layouts/index.ts +0 -24
  97. package/src/server/collections.test.ts +0 -212
  98. package/src/server/contrast.test.ts +0 -82
  99. package/src/server/directory.test.ts +0 -177
  100. package/src/server/github-repo.test.ts +0 -88
  101. package/src/server/models-home.test.ts +0 -101
  102. package/src/server/models.test.ts +0 -135
  103. package/src/server/seo.test.ts +0 -123
  104. package/src/ui/Badge.astro +0 -38
  105. package/src/ui/Button.astro +0 -42
  106. package/src/ui/EmptyState.astro +0 -38
  107. package/src/ui/FilterDrawer.astro +0 -124
  108. package/src/ui/PageHeader.astro +0 -39
  109. package/src/ui/SearchField.astro +0 -106
  110. package/src/ui/button.test.ts +0 -54
  111. package/src/ui/button.ts +0 -107
@@ -1,151 +0,0 @@
1
- ---
2
- import { formatCount } from "@grove-dev/core";
3
- import Icon from "./Icon.astro";
4
- /**
5
- * StackGrid — browse-by-stack grid for the home page.
6
- *
7
- * Renders a card per stack with name, blurb, status pill
8
- * (live/expanding/planned), and a count. The href is the list
9
- * page filtered to that stack, so consumers compose the URL via
10
- * the `hrefFor` prop.
11
- *
12
- * Generic: no blueprint-specific terms. A "stack" here is just a
13
- * slug + display name + status; consumers derive both from their
14
- * own data.
15
- */
16
-
17
- interface StackEntry {
18
- name: string;
19
- slug: string;
20
- blurb?: string;
21
- status?: "live" | "expanding" | "planned";
22
- count?: number;
23
- }
24
-
25
- interface Props {
26
- stacks: StackEntry[];
27
- /** List page base. */
28
- pathPrefix?: string;
29
- /** Stack icon registry — maps slug → icon file name. Optional. */
30
- iconAliases?: Record<string, string>;
31
- /** Extra class. */
32
- class?: string;
33
- /** Hide the section eyebrow / header entirely. */
34
- bare?: boolean;
35
- itemLabelPlural?: string;
36
- /** Noun pair for count chips — singular fixes "1 projects". */
37
- itemNoun?: { singular: string; plural: string };
38
- /** Pass 1 when this grid IS the page (`/stacks` index). */
39
- headingLevel?: 1 | 2;
40
- /**
41
- * Card link builder. Defaults to the browse page filtered by stack
42
- * (`{pathPrefix}?stack=…`); pass e.g. `(slug) => \`/stacks/${slug}\``
43
- * to link to taxonomy detail routes instead.
44
- */
45
- hrefFor?: (slug: string) => string;
46
- }
47
-
48
- const {
49
- stacks,
50
- pathPrefix = "/",
51
- iconAliases,
52
- class: className = "",
53
- bare = false,
54
- itemLabelPlural = "items",
55
- itemNoun = { singular: "item", plural: "items" },
56
- headingLevel = 2,
57
- hrefFor,
58
- } = Astro.props;
59
-
60
- const Heading = `h${headingLevel}` as "h1" | "h2";
61
-
62
- function buildHref(slug: string): string {
63
- if (hrefFor) return hrefFor(slug);
64
- const base = pathPrefix.replace(/\/$/, "");
65
- return `${base}?stack=${encodeURIComponent(slug)}`;
66
- }
67
-
68
- function statusLabel(s: StackEntry["status"]): string | null {
69
- switch (s) {
70
- case "live":
71
- return "Available now";
72
- case "expanding":
73
- return "Expanding";
74
- case "planned":
75
- return "Planned";
76
- default:
77
- return null;
78
- }
79
- }
80
- ---
81
-
82
- <section
83
- id="stacks"
84
- class:list={["py-12 sm:py-16 scroll-mt-20 border-b border-ink-200 dark:border-ink-800", className]}
85
- aria-labelledby={bare ? undefined : "grove-stackgrid-heading"}
86
- >
87
- <div class="mx-auto w-full max-w-container px-5 sm:px-7">
88
- {!bare && (
89
- <header class="mb-6">
90
- <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
91
- Stacks
92
- </span>
93
- <Heading
94
- id="grove-stackgrid-heading"
95
- class="m-0 mt-1 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
96
- >
97
- Browse by stack
98
- </Heading>
99
- <p class="m-0 max-w-[60ch] text-[0.9375rem] text-ink-500 dark:text-ink-400">
100
- Pick a stack to see real {itemLabelPlural} built with it.
101
- </p>
102
- </header>
103
- )}
104
-
105
- <div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
106
- {stacks.map((s) => (
107
- <a
108
- href={buildHref(s.slug)}
109
- class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-card shadow-none transition-colors hover:border-ink-300 dark:hover:border-ink-700 group flex h-full items-center gap-3 p-3.5 no-underline"
110
- data-event="stack_grid_click"
111
- data-event-source="stack_grid"
112
- >
113
- <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-ink-200 bg-white dark:border-ink-800 dark:bg-ink-950" aria-hidden="true">
114
- <Icon name={iconAliases?.[s.slug] ?? s.slug} category="stack" size={18} />
115
- </div>
116
-
117
- <div class="flex min-w-0 flex-1 flex-col gap-0.5">
118
- <div class="flex items-center gap-2">
119
- {/* Not a heading: these tiles are links in a grid, and
120
- heading-per-card breaks the outline when the grid
121
- sits directly under the page h1 (/stacks). */}
122
- <span class="m-0 text-[0.875rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
123
- {s.name}
124
- </span>
125
- {typeof s.count === "number" && (
126
- <span class="inline-flex items-center rounded-md border border-ink-200 bg-ink-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ink-600 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-400">
127
- {formatCount(s.count, itemNoun)}
128
- </span>
129
- )}
130
- </div>
131
- {s.blurb && <p class="m-0 truncate text-2xs text-ink-500 dark:text-ink-400">{s.blurb}</p>}
132
- </div>
133
- {statusLabel(s.status) && (
134
- <span
135
- class:list={[
136
- "shrink-0 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider",
137
- s.status === "live"
138
- ? "!bg-emerald-50 !text-emerald-700 dark:!bg-emerald-950/80 dark:!text-emerald-300"
139
- : s.status === "expanding"
140
- ? "!bg-blue-50 !text-blue-700 dark:!bg-blue-950/80 dark:!text-blue-300"
141
- : "!bg-amber-50 !text-amber-800 dark:!bg-amber-950/80 dark:!text-amber-300",
142
- ]}
143
- >
144
- {statusLabel(s.status)}
145
- </span>
146
- )}
147
- </a>
148
- ))}
149
- </div>
150
- </div>
151
- </section>
@@ -1,73 +0,0 @@
1
- ---
2
- /**
3
- * StackPlatformChips — labelled Stack + Platform chip rows.
4
- *
5
- * Renders up to two rows: one for stack (filled pills, primary) and
6
- * one for platform (outlined pills, secondary). Each row is prefixed
7
- * with a `w-14` (56px) label column so the pills always start at
8
- * the same horizontal position, even when the stack/label is wider
9
- * than the longest platform.
10
- *
11
- * Used by every card-shaped component in the framework so the
12
- * primary/secondary distinction is unambiguous across surfaces
13
- * (homepage grid, directory index, collection rows).
14
- */
15
- import Icon from "./Icon.astro";
16
-
17
- interface Props {
18
- /** Primary technology — filled pill, one entry per item. */
19
- stack?: string;
20
- /** Secondary target platforms — outlined pills, multiple per item. */
21
- platform?: string[];
22
- /** Cap the number of platform pills rendered. Default 4. */
23
- maxPlatform?: number;
24
- /** Hide the row(s) entirely. Default true. */
25
- show?: boolean;
26
- class?: string;
27
- }
28
-
29
- const {
30
- stack,
31
- platform = [],
32
- maxPlatform = 4,
33
- show = true,
34
- class: className = "",
35
- } = Astro.props;
36
-
37
- if (!show || (!stack && platform.length === 0)) {
38
- return Astro.redirect ? null : null;
39
- }
40
-
41
- const visiblePlatform = platform.slice(0, maxPlatform);
42
- const platformOverflow = platform.length - visiblePlatform.length;
43
- ---
44
-
45
- <div class={`flex flex-col gap-1 ${className}`}>
46
- {stack && (
47
- <div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
48
- <span class="w-14 shrink-0 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
49
- Stack
50
- </span>
51
- <span class="inline-flex min-h-5 items-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 text-2xs font-medium leading-none text-secondary-foreground">
52
- <Icon name={stack} category="stack" size={10} />
53
- <span>{stack}</span>
54
- </span>
55
- </div>
56
- )}
57
- {platform.length > 0 && (
58
- <div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
59
- <span class="w-14 shrink-0 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
60
- Platform
61
- </span>
62
- {visiblePlatform.map((p) => (
63
- <span class="inline-flex min-h-5 items-center gap-1 rounded-full border border-ink-200 bg-transparent px-2 py-0.5 text-2xs font-medium leading-none text-ink-500 dark:border-ink-800 dark:text-ink-400">
64
- <Icon name={p} category="platform" size={10} />
65
- <span>{p}</span>
66
- </span>
67
- ))}
68
- {platformOverflow > 0 && (
69
- <span class="text-2xs text-ink-400 dark:text-ink-600">+{platformOverflow}</span>
70
- )}
71
- </div>
72
- )}
73
- </div>
@@ -1,254 +0,0 @@
1
- ---
2
- interface Props {
3
- existingSlugs: string[];
4
- existingFullNames: string[];
5
- repoUrl: string;
6
- fields: { category: boolean; stack: boolean; platforms: boolean; tags: boolean; license?: boolean };
7
- /** Taxonomy ids for client-side validation. */
8
- taxonomy?: {
9
- categoryIds?: string[];
10
- stackIds?: string[];
11
- platformIds?: string[];
12
- };
13
- /**
14
- * Optional path of a server-side proxy endpoint that wraps
15
- * `api.github.com` (SSR consumers with an adapter can wire one and
16
- * read a `GITHUB_TOKEN` env var to lift the rate limit). When
17
- * omitted — the static-build default — the visitor's browser calls
18
- * `https://api.github.com/repos/{owner}/{repo}` directly, which
19
- * works on any static host but is subject to GitHub's per-IP
20
- * unauthenticated limit (60 requests/hour).
21
- */
22
- githubProxyPath?: string;
23
- }
24
- const {
25
- existingSlugs,
26
- existingFullNames,
27
- repoUrl,
28
- fields,
29
- taxonomy,
30
- githubProxyPath = '',
31
- } = Astro.props;
32
- ---
33
-
34
- <script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields, taxonomy, githubProxyPath }}>
35
- /** @typedef {{ name: string; description: string | null; html_url: string; homepage: string | null; language: string | null; private: boolean; topics: string[] }} GitHubRepo */
36
- /** @type {GitHubRepo | null} */
37
- let repository = null;
38
-
39
- const $ = /** @param {string} selector */ (selector) => /** @type {HTMLElement | null} */ (document.querySelector(selector));
40
- /** @type {Record<string, HTMLInputElement | HTMLTextAreaElement | null>} */
41
- const inputs = {
42
- repo: /** @type {HTMLInputElement | null} */ ($("#repo-url")),
43
- name: /** @type {HTMLInputElement | null} */ ($("#item-name")),
44
- slug: /** @type {HTMLInputElement | null} */ ($("#item-slug")),
45
- description: /** @type {HTMLInputElement | null} */ ($("#item-description")),
46
- category: /** @type {HTMLSelectElement | null} */ ($("#item-category")),
47
- stack: /** @type {HTMLSelectElement | null} */ ($("#primary-stack")),
48
- tags: /** @type {HTMLInputElement | null} */ ($("#item-tags")),
49
- license: /** @type {HTMLSelectElement | null} */ ($("#item-license")),
50
- website: /** @type {HTMLInputElement | null} */ ($("#item-website")),
51
- bestFor: /** @type {HTMLTextAreaElement | null} */ ($("#best-for")),
52
- };
53
- const fetchButton = /** @type {HTMLButtonElement | null} */ ($("#fetch-repo"));
54
- const preview = /** @type {HTMLElement | null} */ ($("#yaml-preview"));
55
- const status = /** @type {HTMLElement | null} */ ($("#submit-status"));
56
- const copyButton = /** @type {HTMLButtonElement | null} */ ($("#copy-yaml"));
57
- const openPrLink = /** @type {HTMLAnchorElement | null} */ ($("#open-pr-link"));
58
- const slugSet = new Set(existingSlugs);
59
- const repoSet = new Set(existingFullNames);
60
- const categoryIds = new Set(taxonomy?.categoryIds ?? []);
61
- const stackIds = new Set(taxonomy?.stackIds ?? []);
62
- const platformIds = new Set(taxonomy?.platformIds ?? []);
63
-
64
- const slugify = /** @param {unknown} value */ (value) =>
65
- String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
66
- const parseRepo = /** @param {unknown} value */ (value) => {
67
- const match = String(value ?? "").match(/github\.com\/([^/]+)\/([^/?#]+)/);
68
- return match ? { owner: match[1], repo: match[2].replace(/\.git$/, "") } : null;
69
- };
70
- const quote = /** @param {unknown} value */ (value) =>
71
- `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
72
- const lines = /** @param {string[]} values */ (values, indent = 2) =>
73
- values.map((value) => `${" ".repeat(indent)}- ${value}`).join("\n");
74
- const selectedPlatforms = () =>
75
- /** @type {string[]} */ ([...document.querySelectorAll("input[name=platform]:checked")].map((input) => /** @type {HTMLInputElement} */ (input).value));
76
- const setStatus = (text, tone = "muted") => {
77
- if (!status) return;
78
- status.textContent = text;
79
- // Status tokens (danger/warning/success) so the submit states use
80
- // the same semantic roles as the rest of the system.
81
- status.className = `mt-3 min-h-5 text-2xs ${tone === "error" ? "text-danger" : tone === "warn" ? "text-warning" : tone === "success" ? "text-success" : "text-muted-foreground"}`;
82
- };
83
- const inferStack = /** @param {GitHubRepo} repo */ (repo) => {
84
- const language = String(repo.language || "").toLowerCase();
85
- const topics = (repo.topics || []).map((topic) => String(topic).toLowerCase());
86
- if (topics.includes("flutter") || language === "dart") return "flutter";
87
- if (topics.includes("react-native")) return "react-native";
88
- if (language === "swift" || language === "objective-c") return "ios";
89
- if (language === "kotlin" || language === "java") return "android";
90
- return language;
91
- };
92
- const generateYaml = () => {
93
- if (!repository) return "";
94
- const parsed = parseRepo(inputs.repo?.value ?? "");
95
- if (!parsed) return "";
96
- const tags = String(inputs.tags?.value ?? "").split(",").map((tag) => tag.trim()).filter(Boolean);
97
- const platforms = selectedPlatforms();
98
- const bestFor = String(inputs.bestFor?.value ?? "").split("\n").map((item) => item.trim()).filter(Boolean);
99
- return [
100
- "kind: project",
101
- `slug: ${slugify(inputs.slug?.value ?? repository.name)}`,
102
- `name: ${quote(inputs.name?.value ?? "")}`,
103
- `description: ${quote(inputs.description?.value ?? "")}`,
104
- ...(fields.category ? [`category: ${inputs.category?.value || "uncategorized"}`] : []),
105
- "projectType: real-app",
106
- ...(fields.stack && inputs.stack?.value ? [`stack: ${quote(inputs.stack.value)}`] : []),
107
- ...(fields.platforms ? ["platforms:", platforms.length ? lines(platforms) : " []"] : []),
108
- ...(fields.tags ? ["tags:", tags.length ? lines(tags) : " []"] : []),
109
- ...(fields.license && inputs.license?.value ? ["licenses:", lines([inputs.license.value])] : []),
110
- `repoUrl: ${repository.html_url}`,
111
- "links:",
112
- ` github: ${repository.html_url}`,
113
- ...(inputs.website?.value ? [` website: ${inputs.website.value}`] : []),
114
- "bestFor:", bestFor.length ? lines(bestFor) : " []",
115
- "source:",
116
- " type: manual",
117
- ` owner: ${parsed.owner}`,
118
- ` repo: ${parsed.repo}`,
119
- "curation:",
120
- " reviewed: false",
121
- " labels: []",
122
- " lenses: []",
123
- "",
124
- ].join("\n");
125
- };
126
- const validationIssues = () => {
127
- const issues = [];
128
- if (inputs.slug && slugSet.has(slugify(inputs.slug.value))) {
129
- issues.push("A record with that slug already exists — update the existing one instead of opening a duplicate PR.");
130
- }
131
- const description = String(inputs.description?.value ?? "");
132
- if (description.trim().length < 40) {
133
- issues.push("Description must be at least 40 characters.");
134
- }
135
- if (fields.category && inputs.category) {
136
- const v = inputs.category.value;
137
- if (!v || v === "uncategorized" || (categoryIds.size > 0 && !categoryIds.has(v))) {
138
- issues.push("Choose a category from the taxonomy.");
139
- }
140
- }
141
- if (fields.stack && inputs.stack) {
142
- const v = inputs.stack.value;
143
- if (!v || (stackIds.size > 0 && !stackIds.has(v))) {
144
- issues.push("Choose a primary stack from the taxonomy.");
145
- }
146
- }
147
- if (fields.platforms && selectedPlatforms().length === 0) {
148
- issues.push("Select at least one platform.");
149
- } else if (fields.platforms && platformIds.size > 0) {
150
- const bad = selectedPlatforms().find((p) => !platformIds.has(p));
151
- if (bad) issues.push(`Platform "${bad}" is not in the taxonomy.`);
152
- }
153
- return issues;
154
- };
155
- const refresh = () => {
156
- const yaml = generateYaml();
157
- if (preview) preview.textContent = yaml || "Paste a GitHub URL to generate a draft.";
158
- if (!yaml || !openPrLink || !copyButton) return;
159
- const issues = validationIssues();
160
- if (issues.length > 0) {
161
- setStatus(issues[0], "error");
162
- openPrLink.classList.add("pointer-events-none", "opacity-50");
163
- copyButton.disabled = true;
164
- copyButton.classList.add("pointer-events-none", "opacity-50");
165
- return;
166
- }
167
- const params = new URLSearchParams({ filename: `data/records/${slugify(inputs.slug?.value ?? "")}.yml`, value: yaml });
168
- openPrLink.href = `${repoUrl}/new/main?${params}`;
169
- openPrLink.classList.remove("pointer-events-none", "opacity-50");
170
- copyButton.disabled = false;
171
- copyButton.classList.remove("pointer-events-none", "opacity-50");
172
- };
173
-
174
- fetchButton?.addEventListener("click", async () => {
175
- const parsed = parseRepo(inputs.repo?.value ?? "");
176
- if (!parsed) return setStatus("Enter a valid GitHub repository URL.", "error");
177
- const fullName = `${parsed.owner}/${parsed.repo}`.toLowerCase();
178
- if (repoSet.has(fullName)) setStatus("This repository is already listed. The draft can update its record.", "warn");
179
- if (fetchButton) fetchButton.disabled = true;
180
- setStatus("Fetching GitHub metadata...");
181
- try {
182
- if (githubProxyPath) {
183
- // Server-side proxy (SSR consumers) — the endpoint reads an
184
- // optional `GITHUB_TOKEN` env var and forwards the request
185
- // with a Bearer header (or unauthenticated, if no token).
186
- const proxyUrl = `${githubProxyPath}?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}`;
187
- const response = await fetch(proxyUrl);
188
- const payload = /** @type {{ ok: boolean; status?: number; message?: string; data?: GitHubRepo }} */ (await response.json());
189
- if (!response.ok || !payload.ok || !payload.data) {
190
- throw new Error(payload.message || `GitHub returned ${response.status}.`);
191
- }
192
- repository = payload.data;
193
- } else {
194
- // Static-build default — call api.github.com directly. The
195
- // raw payload carries the same field names the form fill
196
- // logic consumes (name, description, html_url, homepage,
197
- // language, private, topics).
198
- const response = await fetch(
199
- `https://api.github.com/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}`,
200
- { headers: { Accept: "application/vnd.github+json" } },
201
- );
202
- if (response.status === 404) {
203
- throw new Error("Repository not found or not public.");
204
- }
205
- if (response.status === 403 || response.status === 429) {
206
- throw new Error(
207
- "GitHub rate limit reached (60 requests/hour per visitor). Try again later or fill the fields manually.",
208
- );
209
- }
210
- if (!response.ok) {
211
- throw new Error(`GitHub returned ${response.status}.`);
212
- }
213
- const data = /** @type {GitHubRepo & { topics?: string[] }} */ (await response.json());
214
- repository = {
215
- name: data.name,
216
- description: data.description ?? null,
217
- html_url: data.html_url,
218
- homepage: data.homepage ?? null,
219
- language: data.language ?? null,
220
- private: Boolean(data.private),
221
- topics: Array.isArray(data.topics) ? data.topics : [],
222
- };
223
- }
224
- if (repository.private) throw new Error("Private repositories cannot be submitted.");
225
- if (inputs.name) inputs.name.value = repository.name || parsed.repo;
226
- if (inputs.slug) inputs.slug.value = slugify(repository.name || parsed.repo);
227
- if (inputs.description) inputs.description.value = repository.description || "";
228
- if (inputs.stack) inputs.stack.value = inferStack(repository);
229
- if (inputs.tags) inputs.tags.value = (repository.topics || []).slice(0, 8).join(", ");
230
- if (inputs.website) inputs.website.value = repository.homepage || "";
231
- const duplicate = inputs.slug ? slugSet.has(inputs.slug.value) : false;
232
- setStatus(
233
- duplicate ? "That slug already exists. Choose another or update the existing record." : "Draft generated. Edit any field and the YAML will refresh.",
234
- duplicate ? "warn" : "success",
235
- );
236
- refresh();
237
- } catch (error) {
238
- repository = null;
239
- setStatus(error instanceof Error ? error.message : "Unable to fetch repository metadata.", "error");
240
- } finally {
241
- if (fetchButton) fetchButton.disabled = false;
242
- }
243
- });
244
- $("#submit-form")?.addEventListener("input", refresh);
245
- $("#submit-form")?.addEventListener("change", refresh);
246
- copyButton?.addEventListener("click", async () => {
247
- try {
248
- await navigator.clipboard.writeText(preview?.textContent || "");
249
- setStatus("Copied YAML to clipboard.", "success");
250
- } catch {
251
- setStatus("Couldn't copy automatically. Select the YAML and copy it.", "error");
252
- }
253
- });
254
- </script>
@@ -1,140 +0,0 @@
1
- ---
2
- /**
3
- * TableOfContents.astro
4
- *
5
- * Collapsible on-page nav for a record's Markdown body.
6
- *
7
- * Behavior:
8
- * - Mobile (<lg): rendered as a closed `<details>` dropdown.
9
- * - Desktop (lg+): forced open by the `.grove-toc` media rule in
10
- * `styles.css` (CSS reveals the list without the `open`
11
- * attribute) so the nav stays visible while the user scrolls.
12
- * - Heading IDs come from the markdown renderer; the TOC entries
13
- * are pre-computed by `extractToc` and passed in.
14
- *
15
- * The scroll-spy + smooth-scroll logic lives in an inline script
16
- * that finds every `data-toc-link` on the page and highlights the
17
- * link whose target is closest to the top of the viewport (rAF-
18
- * throttled). Active styling comes from the `data-toc-active`
19
- * attribute toggled in the script + CSS rules in `styles.css`.
20
- */
21
- import type { TocEntry } from "@grove-dev/core";
22
-
23
- interface Props {
24
- /** Heading entries from `extractToc`. */
25
- items: TocEntry[];
26
- /** Optional accessible label override. */
27
- label?: string;
28
- }
29
-
30
- const { items, label = "On this page" } = Astro.props;
31
- const hasItems = items.length > 1;
32
- ---
33
-
34
- {hasItems && (
35
- <details class="grove-toc lg:border-0">
36
- <summary class="cursor-pointer list-none pb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:cursor-default lg:pointer-events-none lg:pb-2">
37
- <span class="inline-flex items-center gap-1.5">
38
- <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="grove-toc-chevron transition-transform lg:hidden">
39
- <path d="M6 4l4 4-4 4V4z" />
40
- </svg>
41
- {label}
42
- </span>
43
- </summary>
44
- {/* Full list, no inner scroll — the TOC lives in the right rail
45
- and every entry stays visible. h3 entries indent further under
46
- their h2 parents; the active state is a left-rail indicator +
47
- text-color emphasis set by the script + styles.css. */}
48
- <ol class="grove-toc-list list-none py-1 text-sm lg:py-0">
49
- {items.map((entry) => (
50
- <li
51
- class:list={[
52
- "grove-toc-item relative",
53
- entry.depth >= 3 && "grove-toc-item--h3",
54
- ]}
55
- >
56
- <a
57
- href={`#${entry.id}`}
58
- class:list={[
59
- "grove-toc-link block py-1.5 text-ink-500 no-underline transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100",
60
- entry.depth >= 2 && "pl-3 lg:pl-4",
61
- entry.depth >= 3 && "pl-6 text-[0.8125rem] lg:pl-7",
62
- ]}
63
- data-toc-link={entry.id}
64
- >
65
- {entry.text}
66
- </a>
67
- </li>
68
- ))}
69
- </ol>
70
- </details>
71
- )}
72
-
73
- {hasItems && (
74
- <script is:inline define:vars={{ tocIds: items.map((i) => i.id) }}>
75
- (function () {
76
- // Desktop (lg+): force the details open so the nav stays visible
77
- // while scrolling; the summary is pointer-events-none there. On
78
- // narrow viewports restore the closed dropdown default. CSS in
79
- // styles.css covers the no-JS case.
80
- var toc = document.querySelector(".grove-toc");
81
- if (toc) {
82
- var mq = window.matchMedia("(min-width: 64rem)");
83
- var syncOpen = function () { toc.open = mq.matches; };
84
- if (typeof mq.addEventListener === "function") {
85
- mq.addEventListener("change", syncOpen);
86
- }
87
- syncOpen();
88
- }
89
-
90
- // Smooth scroll + history-replace on TOC link clicks.
91
- function onClick(e) {
92
- var link = e.target.closest("a[data-toc-link]");
93
- if (!link) return;
94
- e.preventDefault();
95
- var id = link.getAttribute("data-toc-link");
96
- var target = document.getElementById(id);
97
- if (!target) return;
98
- target.scrollIntoView({ behavior: "smooth", block: "start" });
99
- history.replaceState(null, "", "#" + id);
100
- }
101
- document.addEventListener("click", onClick);
102
-
103
- // Scroll-spy: rAF-throttled, toggles `data-toc-active` on the
104
- // link whose heading is closest to the top of the viewport.
105
- // CSS handles the active styling — no dynamic Tailwind classes.
106
- var headings = tocIds
107
- .map(function (id) { return document.getElementById(id); })
108
- .filter(function (el) { return !!el; });
109
- if (!headings.length) return;
110
- var links = new Map();
111
- document.querySelectorAll("a[data-toc-link]").forEach(function (a) {
112
- links.set(a.getAttribute("data-toc-link"), a);
113
- });
114
- var active = null;
115
- var ticking = false;
116
- function update() {
117
- ticking = false;
118
- var top = window.scrollY + 140;
119
- var current = headings[0].id;
120
- for (var i = 0; i < headings.length; i++) {
121
- if (headings[i].offsetTop <= top) current = headings[i].id;
122
- else break;
123
- }
124
- if (current === active) return;
125
- active = current;
126
- links.forEach(function (a, id) {
127
- if (id === current) a.setAttribute("data-toc-active", "");
128
- else a.removeAttribute("data-toc-active");
129
- });
130
- }
131
- function onScroll() {
132
- if (ticking) return;
133
- ticking = true;
134
- requestAnimationFrame(update);
135
- }
136
- window.addEventListener("scroll", onScroll, { passive: true });
137
- update();
138
- })();
139
- </script>
140
- )}