@grove-dev/astro 0.5.3 → 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/Icon.astro +8 -0
- 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
package/src/layouts/Seo.astro
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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;
|
|
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() + '
|
|
85
|
+
return s.slice(0, max - 1).trimEnd() + '…';
|
|
71
86
|
}
|
|
72
87
|
const shortDescription = truncate(description, 200);
|
|
73
88
|
|
|
74
|
-
// WebSite
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
105
|
-
const
|
|
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
|
|
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={
|
|
149
|
+
<meta property="og:image:alt" content={ogImageAlt} />
|
|
124
150
|
<meta property="og:site_name" content={siteConfig.name} />
|
|
125
|
-
<meta property="og:locale" content=
|
|
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={
|
|
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={
|
|
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
|
|
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 {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
127
|
-
*
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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:
|
|
209
|
-
|
|
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
|
package/src/server/index.ts
CHANGED