@grove-dev/astro 0.5.4 → 0.6.1

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 (41) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +8 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/index.d.ts +0 -1
  5. package/dist/lib/index.d.ts.map +1 -1
  6. package/dist/lib/index.js +0 -1
  7. package/dist/lib/index.js.map +1 -1
  8. package/dist/ui/button.d.ts +8 -0
  9. package/dist/ui/button.d.ts.map +1 -1
  10. package/dist/ui/button.js +8 -0
  11. package/dist/ui/button.js.map +1 -1
  12. package/package.json +2 -2
  13. package/src/components/DirectoryIndexClient.astro +351 -178
  14. package/src/components/Hero.astro +15 -11
  15. package/src/components/Pagination.astro +3 -3
  16. package/src/components/PoweredBy.astro +60 -0
  17. package/src/components/ProjectCard.astro +42 -25
  18. package/src/components/RecordHeader.astro +14 -9
  19. package/src/components/RefinePanel.astro +11 -2
  20. package/src/components/TableOfContents.astro +11 -12
  21. package/src/index.ts +8 -0
  22. package/src/layouts/BaseLayout.astro +14 -1
  23. package/src/layouts/Footer.astro +18 -2
  24. package/src/layouts/Header.astro +1 -1
  25. package/src/layouts/Seo.astro +64 -38
  26. package/src/lib/index.ts +0 -1
  27. package/src/server/collections.ts +96 -67
  28. package/src/server/index.ts +1 -0
  29. package/src/server/models.ts +456 -25
  30. package/src/server/seo.test.ts +123 -0
  31. package/src/server/seo.ts +141 -0
  32. package/src/styles.css +23 -6
  33. package/src/ui/FilterDrawer.astro +25 -5
  34. package/src/ui/SearchField.astro +58 -2
  35. package/src/ui/button.test.ts +1 -1
  36. package/src/ui/button.ts +9 -0
  37. package/dist/lib/load-collections.d.ts +0 -12
  38. package/dist/lib/load-collections.d.ts.map +0 -1
  39. package/dist/lib/load-collections.js +0 -34
  40. package/dist/lib/load-collections.js.map +0 -1
  41. package/src/lib/load-collections.ts +0 -33
@@ -8,16 +8,20 @@
8
8
  * has a sensible default derived from the site config and the Astro URL.
9
9
  *
10
10
  * Renders:
11
- * - <title>, meta description, canonical link
11
+ * - <title>, meta description, canonical link, robots
12
12
  * - Open Graph (og:*) for Facebook, LinkedIn, Discord, Slack, etc.
13
13
  * - Twitter Card (twitter:*) for X / Twitter
14
- * - JSON-LD WebSite + Organization on every page
15
- * - JSON-LD SoftwareSourceCode (or custom) when `jsonLd` is provided
16
- * (e.g. on /items/[slug] for the per-item structured data)
14
+ * - JSON-LD WebSite+Organization identity graph on every page
15
+ * (built by @grove-dev/core's `siteSchema`, with a SearchAction
16
+ * pointing at the directory's browse page)
17
+ * - Per-page JSON-LD nodes when `jsonLd` is provided (SoftwareSourceCode
18
+ * on record pages, CollectionPage/ItemList/BreadcrumbList on
19
+ * collection and taxonomy pages, ...)
17
20
  *
18
21
  * Noindex is opt-in via the `noindex` prop. By default everything is
19
- * indexable; the only noindex target today is the thin /submit wrapper.
22
+ * indexable; noindex targets today are /submit, /404, and /empty.
20
23
  */
24
+ import { siteSchema, validateJsonLd, type JsonLdNode } from '@grove-dev/core';
21
25
 
22
26
  interface Props {
23
27
  title: string;
@@ -26,10 +30,18 @@ interface Props {
26
30
  name: string;
27
31
  description?: string;
28
32
  repoUrl?: string;
33
+ /** Header logo path under `public/` — used as Organization logo when set. */
34
+ logo?: string;
35
+ /** BCP-47 language tag (e.g. "en"). Drives og:locale + JSON-LD inLanguage. */
36
+ locale?: string;
37
+ /** Twitter/X handle (e.g. "@grove") — emitted as twitter:site. */
38
+ twitter?: string;
29
39
  };
30
40
  searchPath?: string;
31
41
  /** Open Graph / Twitter image. Default: /og-image.svg. Should be absolute or root-relative. */
32
42
  image?: string;
43
+ /** Alt text for the OG/Twitter image. Default: "<site name> social preview". */
44
+ imageAlt?: string;
33
45
  /** og:type. Default: "website". Use "article" for blog posts / changelog. */
34
46
  type?: 'website' | 'article' | 'profile';
35
47
  /** Set true to add noindex,nofollow. */
@@ -44,6 +56,7 @@ const {
44
56
  site: siteConfig,
45
57
  searchPath = '/items',
46
58
  image = '/og-image.svg',
59
+ imageAlt,
47
60
  type = 'website',
48
61
  noindex = false,
49
62
  jsonLd,
@@ -63,54 +76,67 @@ if (!Astro.site) {
63
76
  const siteUrl = Astro.site;
64
77
  const canonical = new URL(Astro.url.pathname, siteUrl).toString();
65
78
  const ogImage = new URL(image, siteUrl).toString();
79
+ const ogImageAlt = imageAlt ?? `${siteConfig.name} social preview`;
80
+ const locale = siteConfig.locale ?? 'en';
66
81
 
67
82
  // Truncate description for OG/Twitter where longer is worse (160–200 chars).
68
83
  function truncate(s: string, max: number): string {
69
84
  if (s.length <= max) return s;
70
- return s.slice(0, max - 1).trimEnd() + '\u2026';
85
+ return s.slice(0, max - 1).trimEnd() + '';
71
86
  }
72
87
  const shortDescription = truncate(description, 200);
73
88
 
74
- // WebSite + Organization JSON-LD. These are site-wide identity signals
75
- // that should appear on every page (Google uses them for site links
76
- // and the knowledge panel).
77
- const websiteLd = {
78
- '@context': 'https://schema.org',
79
- '@type': 'WebSite',
89
+ // WebSite+Organization identity graph site-wide signals that appear on
90
+ // every page (Google uses them for site links and the knowledge panel).
91
+ // Built by core's `siteSchema` so the @id graph shape is shared with the
92
+ // rest of the JSON-LD registry; the SearchAction is appended here because
93
+ // the browse path is an adapter concern, not a core one.
94
+ const bareSiteUrl = siteUrl.toString().replace(/\/$/, '');
95
+ const [siteLd] = siteSchema({
96
+ url: bareSiteUrl,
80
97
  name: siteConfig.name,
81
98
  description: siteConfig.description ?? description,
82
- url: siteUrl.toString().replace(/\/$/, ''),
83
- inLanguage: 'en',
84
- potentialAction: {
85
- '@type': 'SearchAction',
86
- target: `${siteUrl.toString().replace(/\/$/, '')}${searchPath}?q={search_term_string}`,
87
- 'query-input': 'required name=search_term_string',
88
- },
89
- };
90
-
91
- const organizationLd = {
92
- '@context': 'https://schema.org',
93
- '@type': 'Organization',
94
- name: siteConfig.name,
95
- url: siteUrl.toString().replace(/\/$/, ''),
96
- logo: new URL('/og-image.svg', siteUrl).toString(),
99
+ inLanguage: locale,
100
+ orgName: siteConfig.name,
101
+ orgUrl: bareSiteUrl,
102
+ ...(siteConfig.logo ? { orgLogo: new URL(siteConfig.logo, siteUrl).toString() } : {}),
97
103
  ...(siteConfig.repoUrl ? { sameAs: [siteConfig.repoUrl] } : {}),
104
+ });
105
+ siteLd.potentialAction = {
106
+ '@type': 'SearchAction',
107
+ target: `${bareSiteUrl}${searchPath}?q={search_term_string}`,
108
+ 'query-input': 'required name=search_term_string',
98
109
  };
99
110
 
111
+ const perPageNodes = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : null;
112
+
113
+ // Dev-only well-formedness check — surfaces malformed nodes (relative
114
+ // URLs, missing @context, duplicate @ids) in the terminal during
115
+ // `astro dev` without ever failing or slowing a production build.
116
+ if (import.meta.env.DEV && perPageNodes) {
117
+ const issues = validateJsonLd(perPageNodes as JsonLdNode[]);
118
+ for (const issue of issues) {
119
+ console.warn(`[grove seo] ${Astro.url.pathname}: ${issue.message}`);
120
+ }
121
+ }
122
+
100
123
  // Pre-stringify the JSON-LD payloads in the frontmatter so the
101
124
  // template body just interpolates the resulting strings. For SSR
102
125
  // this saves the JSON.stringify call on every render; for static
103
126
  // builds the cost is unchanged but the template reads cleaner.
104
- const websiteLdJson = JSON.stringify(websiteLd);
105
- const organizationLdJson = JSON.stringify(organizationLd);
106
- const perPageLdJson = jsonLd ? JSON.stringify(Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : null;
127
+ const siteLdJson = JSON.stringify(siteLd);
128
+ const perPageLdJson = perPageNodes ? JSON.stringify(perPageNodes) : null;
107
129
  ---
108
130
 
109
131
  <!-- Primary meta -->
110
132
  <title>{title}</title>
111
133
  <meta name="description" content={description} />
112
134
  <link rel="canonical" href={canonical} />
113
- {noindex && <meta name="robots" content="noindex,nofollow" />}
135
+ {noindex ? (
136
+ <meta name="robots" content="noindex,nofollow" />
137
+ ) : (
138
+ <meta name="robots" content="index,follow,max-image-preview:large" />
139
+ )}
114
140
 
115
141
  <!-- Open Graph -->
116
142
  <meta property="og:type" content={type} />
@@ -120,22 +146,22 @@ const perPageLdJson = jsonLd ? JSON.stringify(Array.isArray(jsonLd) ? jsonLd : [
120
146
  <meta property="og:image" content={ogImage} />
121
147
  <meta property="og:image:width" content="1200" />
122
148
  <meta property="og:image:height" content="630" />
123
- <meta property="og:image:alt" content={`${siteConfig.name} social preview`} />
149
+ <meta property="og:image:alt" content={ogImageAlt} />
124
150
  <meta property="og:site_name" content={siteConfig.name} />
125
- <meta property="og:locale" content="en" />
151
+ <meta property="og:locale" content={locale} />
126
152
 
127
153
  <!-- Twitter Card -->
128
154
  <meta name="twitter:card" content="summary_large_image" />
155
+ {siteConfig.twitter && <meta name="twitter:site" content={siteConfig.twitter} />}
129
156
  <meta name="twitter:title" content={title} />
130
157
  <meta name="twitter:description" content={shortDescription} />
131
158
  <meta name="twitter:image" content={ogImage} />
132
- <meta name="twitter:image:alt" content={`${siteConfig.name} social preview`} />
159
+ <meta name="twitter:image:alt" content={ogImageAlt} />
133
160
 
134
- <!-- JSON-LD: site-wide identity -->
135
- <script is:inline type="application/ld+json" set:html={websiteLdJson} />
136
- <script is:inline type="application/ld+json" set:html={organizationLdJson} />
161
+ <!-- JSON-LD: site-wide identity graph -->
162
+ <script is:inline type="application/ld+json" set:html={siteLdJson} />
137
163
 
138
- <!-- JSON-LD: per-page (SoftwareSourceCode for /items/[slug], etc.) -->
164
+ <!-- JSON-LD: per-page (SoftwareSourceCode, CollectionPage, BreadcrumbList, ...) -->
139
165
  {perPageLdJson && (
140
166
  <script
141
167
  is:inline
package/src/lib/index.ts CHANGED
@@ -23,7 +23,6 @@ export * from "./lenses.js";
23
23
  export * from "./search.js";
24
24
  export * from "./taxonomy-counts.js";
25
25
  export * from "./display.js";
26
- export * from "./load-collections.js";
27
26
  export * from "./icon-registry.js";
28
27
  export * from "./icon-kinds.js";
29
28
  export * from "./packaged-icons.js";
@@ -1,8 +1,10 @@
1
1
  import type { Collection, CollectionEntry } from "@grove-dev/core";
2
- import { findRelated, runCollection } from "@grove-dev/core";
3
- import { readFile, readdir } from "node:fs/promises";
4
- import { join, resolve } from "node:path";
5
- import { parse as parseYaml } from "yaml";
2
+ import { collectionSchema, findRelated, runCollection } from "@grove-dev/core";
3
+ import { absoluteUrl, ogPath, type PageSeo, seoDescription, seoTitle } from "./seo.js";
4
+
5
+ // Collection YAML loading lives in @grove-dev/core (it also feeds the
6
+ // sitemap and OG-image pipelines there); re-exported for page code.
7
+ export { loadCollections } from "@grove-dev/core";
6
8
 
7
9
  interface RouteHint {
8
10
  routeSlug?: string;
@@ -123,10 +125,12 @@ export interface CollectionPageModel {
123
125
  total: number;
124
126
  isEmpty: boolean;
125
127
  entries: CollectionEntry[];
126
- /** ItemList JSON-LD block. Pass to BaseLayout as the `jsonLd` prop
127
- * so it ships in the document head and is indexable by search
128
- * engines as a list of `SoftwareApplication` items. */
128
+ /** CollectionPage + ItemList + BreadcrumbList JSON-LD nodes. Ships
129
+ * through `seo.jsonLd`; kept here too for backward compatibility. */
129
130
  jsonLd?: unknown;
131
+ /** Complete head block: honors the collection's `seo.title`,
132
+ * `seo.description`, and `seo.index` overrides. Pass to BaseLayout. */
133
+ seo: PageSeo;
130
134
  related: Array<{ slug: string; title: string; url: string }>;
131
135
  }
132
136
 
@@ -140,6 +144,9 @@ export interface CollectionIndexModel {
140
144
  count: number;
141
145
  url: string;
142
146
  }>;
147
+ /** Head block for the /collections/ index — present when the caller
148
+ * passes a site config. */
149
+ seo?: PageSeo;
143
150
  }
144
151
 
145
152
  export interface CollectionTeaserModel {
@@ -151,7 +158,7 @@ export function getCollectionPageModel(
151
158
  collection: Collection,
152
159
  entries: CollectionEntry[],
153
160
  allCollections: Collection[],
154
- site?: { name?: string; url?: string },
161
+ site?: { name?: string; url?: string; siteUrl?: string; blueprintConfig?: { labelPlural?: string } },
155
162
  ): CollectionPageModel {
156
163
  const result = runCollection(collection, entries);
157
164
  const related = findRelated(collection, allCollections, 4).map((c) => ({
@@ -159,28 +166,43 @@ export function getCollectionPageModel(
159
166
  title: c.title,
160
167
  url: `/collections/${c.slug}/`,
161
168
  }));
162
- // ItemList JSON-LD. Search engines can use this to surface
163
- // individual entries directly from the collection URL.
164
- // Cap at 50 entries to keep the JSON-LD payload bounded;
165
- // search engines don't index beyond that anyway.
166
- const itemListElement = result.entries.slice(0, 50).map((entry, index) => ({
167
- "@type": "ListItem",
168
- position: index + 1,
169
- item: {
170
- "@type": "SoftwareApplication",
169
+ const siteUrl = (site?.siteUrl ?? site?.url ?? "https://example.com").replace(/\/$/, "");
170
+ const siteName = site?.name ?? "";
171
+ const plural = site?.blueprintConfig?.labelPlural ?? "items";
172
+ const pageUrl = absoluteUrl(siteUrl, `collections/${collection.slug}/`);
173
+ // CollectionPage + ItemList + BreadcrumbList. The ItemList is capped
174
+ // at 50 entries to keep the payload bounded; search engines don't
175
+ // index list markup beyond that anyway.
176
+ const jsonLd = collectionSchema({
177
+ url: pageUrl,
178
+ name: collection.seo?.title ?? collection.title,
179
+ description: collection.seo?.description ?? collection.description,
180
+ items: result.entries.slice(0, 50).map((entry) => ({
181
+ url: entry.url.startsWith("http") ? entry.url : absoluteUrl(siteUrl, entry.url),
171
182
  name: entry.title,
172
- url: entry.url,
173
- description: entry.description,
174
- },
175
- }));
176
- const jsonLd = {
177
- "@context": "https://schema.org",
178
- "@type": "ItemList",
179
- name: collection.title,
180
- description: collection.description,
181
- url: site?.url ? `${site.url.replace(/\/$/, "")}/collections/${collection.slug}/` : undefined,
182
- numberOfItems: result.entries.length,
183
- itemListElement,
183
+ ...(entry.description ? { description: entry.description } : {}),
184
+ })),
185
+ crumbs: [
186
+ { url: `${siteUrl}/`, name: "Home" },
187
+ { url: absoluteUrl(siteUrl, "collections/"), name: "Collections" },
188
+ { url: pageUrl, name: collection.title },
189
+ ],
190
+ });
191
+ // The curator's `seo.title` / `seo.description` overrides win
192
+ // verbatim; the fallback pattern advertises the list size. A
193
+ // collection marked `seo.index: false` renders with noindex.
194
+ const seo: PageSeo = {
195
+ title:
196
+ collection.seo?.title ??
197
+ seoTitle(`${collection.title} — ${result.entries.length} ${plural}`, siteName),
198
+ description: seoDescription(
199
+ collection.seo?.description,
200
+ collection.description,
201
+ ),
202
+ image: ogPath("collection", collection.slug),
203
+ ...(siteName ? { imageAlt: `${collection.title} — ${siteName}` } : {}),
204
+ jsonLd: jsonLd as unknown as Record<string, unknown>[],
205
+ noindex: collection.seo?.index === false,
184
206
  };
185
207
  return {
186
208
  collection: {
@@ -196,26 +218,60 @@ export function getCollectionPageModel(
196
218
  entries: result.entries,
197
219
  related,
198
220
  jsonLd,
221
+ seo,
199
222
  };
200
223
  }
201
224
 
202
225
  export function getCollectionIndexModel(
203
226
  collections: Collection[],
204
227
  entries: CollectionEntry[],
228
+ site?: { name?: string; url?: string; siteUrl?: string; blueprintConfig?: { labelPlural?: string } },
205
229
  ): CollectionIndexModel {
230
+ const rows = collections.map((c) => {
231
+ const result = runCollection(c, entries);
232
+ return {
233
+ slug: c.slug,
234
+ title: c.title,
235
+ description: c.description,
236
+ kind: c.kind,
237
+ count: result.entries.length,
238
+ url: `/collections/${c.slug}/`,
239
+ };
240
+ });
241
+ let seo: PageSeo | undefined;
242
+ if (site) {
243
+ const siteUrl = (site.siteUrl ?? site.url ?? "https://example.com").replace(/\/$/, "");
244
+ const siteName = site.name ?? "";
245
+ const plural = site.blueprintConfig?.labelPlural ?? "items";
246
+ const title = seoTitle("Collections", siteName);
247
+ const description = seoDescription(
248
+ undefined,
249
+ `${rows.length} curated and generated collections of ${plural} on ${siteName || "this site"} — hand-picked lists kept in sync with the source files.`,
250
+ );
251
+ seo = {
252
+ title,
253
+ description,
254
+ image: ogPath("default"),
255
+ jsonLd: collectionSchema({
256
+ url: absoluteUrl(siteUrl, "collections/"),
257
+ name: title,
258
+ description,
259
+ items: rows.map((row) => ({
260
+ url: absoluteUrl(siteUrl, row.url),
261
+ name: row.title,
262
+ ...(row.description ? { description: row.description } : {}),
263
+ })),
264
+ crumbs: [
265
+ { url: `${siteUrl}/`, name: "Home" },
266
+ { url: absoluteUrl(siteUrl, "collections/"), name: "Collections" },
267
+ ],
268
+ }) as unknown as Record<string, unknown>[],
269
+ };
270
+ }
206
271
  return {
207
272
  total: collections.length,
208
- collections: collections.map((c) => {
209
- const result = runCollection(c, entries);
210
- return {
211
- slug: c.slug,
212
- title: c.title,
213
- description: c.description,
214
- kind: c.kind,
215
- count: result.entries.length,
216
- url: `/collections/${c.slug}/`,
217
- };
218
- }),
273
+ collections: rows,
274
+ ...(seo ? { seo } : {}),
219
275
  };
220
276
  }
221
277
 
@@ -231,33 +287,6 @@ export function getCollectionTeaserModel(
231
287
  };
232
288
  }
233
289
 
234
- /**
235
- * Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
236
- *
237
- * Returns an empty array if the directory does not exist. Parse
238
- * errors are NOT swallowed — they surface so real problems
239
- * (malformed YAML, permission errors) are not hidden.
240
- */
241
- export async function loadCollections(cwd: string): Promise<Collection[]> {
242
- const dir = resolve(cwd, "data/collections");
243
- let files: string[];
244
- try {
245
- files = await readdir(dir);
246
- } catch (err) {
247
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
248
- throw err;
249
- }
250
- const out: Collection[] = [];
251
- for (const f of files.filter((f) => f.endsWith(".yml"))) {
252
- const raw = parseYaml(await readFile(join(dir, f), "utf8"));
253
- if (!raw || typeof raw !== "object") {
254
- throw new Error(`Invalid collection YAML: ${f}`);
255
- }
256
- out.push(raw as Collection);
257
- }
258
- return out;
259
- }
260
-
261
290
  /**
262
291
  * Reverse lookup — given a record, return the slugs of every
263
292
  * curated collection that includes it. Walks each collection's
@@ -2,3 +2,4 @@ export * from './directory.js';
2
2
  export * from './models.js';
3
3
  export * from './collections.js';
4
4
  export * from './github-repo.js';
5
+ export * from './seo.js';