@grove-dev/astro 0.2.16 → 0.2.19
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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/display.js +4 -4
- package/dist/lib/display.js.map +1 -1
- package/dist/lib/repo.d.ts.map +1 -1
- package/dist/lib/repo.js +3 -4
- package/dist/lib/repo.js.map +1 -1
- package/dist/lib/search.d.ts +2 -2
- package/dist/lib/search.d.ts.map +1 -1
- package/dist/lib/search.js +17 -6
- package/dist/lib/search.js.map +1 -1
- package/dist/theme.test.d.ts +2 -0
- package/dist/theme.test.d.ts.map +1 -0
- package/dist/theme.test.js +41 -0
- package/dist/theme.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/FilterGroupMenu.astro +3 -1
- package/src/components/Hero.astro +3 -3
- package/src/components/Icon.astro +4 -4
- package/src/components/IndexRow.astro +15 -2
- package/src/index.ts +52 -1
- package/src/layouts/BaseLayout.astro +12 -11
- package/src/layouts/Seo.astro +12 -4
- package/src/lib/display.ts +4 -4
- package/src/lib/repo.ts +3 -4
- package/src/lib/search.ts +18 -8
- package/src/styles.css +184 -69
- package/src/theme.test.ts +52 -0
- package/templates/default/package.json +2 -1
- package/templates/default/public/llms-full.txt +1 -1
- package/templates/default/public/sitemap.xml +10 -10
- package/templates/default/src/data/records.ts +115 -77
- package/templates/default/src/pages/[slug]/[recordSlug].astro +29 -33
- package/templates/default/src/pages/[slug]/index.astro +20 -296
- package/templates/default/src/styles/global.css +14 -39
- package/templates/default/tailwind.config.mjs +0 -130
|
@@ -24,14 +24,20 @@
|
|
|
24
24
|
* blueprints — `project-directory` (default), `resource-hub`, and
|
|
25
25
|
* `ecosystem-map` — without per-blueprint forks.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* JSON imports are static at the top of this module so Vite
|
|
28
|
+
* resolves them at build time. A fresh scaffold that has not yet
|
|
29
|
+
* run `grove generate` will fail the build with a clear Vite
|
|
30
|
+
* resolution error, which is the correct signal — a build that
|
|
31
|
+
* silently renders an empty directory hides real config mistakes.
|
|
31
32
|
*/
|
|
33
|
+
import fullPayload from "../../data/generated/records.full.json";
|
|
34
|
+
import indexPayload from "../../data/generated/records.index.json";
|
|
35
|
+
import siteConfigPayload from "../../data/generated/site-config.json";
|
|
32
36
|
import { existsSync, readFileSync } from "node:fs";
|
|
33
37
|
import { fileURLToPath } from "node:url";
|
|
34
38
|
import { dirname, resolve } from "node:path";
|
|
39
|
+
import { marked } from "marked";
|
|
40
|
+
import sanitizeHtml from "sanitize-html";
|
|
35
41
|
import type {
|
|
36
42
|
ProjectRecord,
|
|
37
43
|
ResourceRecord,
|
|
@@ -76,87 +82,26 @@ interface SiteConfigPayload {
|
|
|
76
82
|
name?: string;
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// scaffold runs `astro build` without first running
|
|
83
|
-
// `grove generate`, in which case the JSON file does not exist.
|
|
84
|
-
// Resolve the path relative to this module's URL, then fall back
|
|
85
|
-
// to an empty list.
|
|
86
|
-
try {
|
|
87
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
88
|
-
const candidates = [
|
|
89
|
-
resolve(here, "..", "..", "data", "generated", filename),
|
|
90
|
-
resolve(here, "..", "..", "..", "data", "generated", filename),
|
|
91
|
-
resolve(process.cwd(), "data", "generated", filename),
|
|
92
|
-
];
|
|
93
|
-
const path = candidates.find((p) => existsSync(p));
|
|
94
|
-
if (!path) return parser("");
|
|
95
|
-
return parser(readFileSync(path, "utf8"));
|
|
96
|
-
} catch {
|
|
97
|
-
return parser("");
|
|
98
|
-
}
|
|
99
|
-
}
|
|
85
|
+
const fullRecordsRaw: Resource[] = (fullPayload as FullPayload).records ?? [];
|
|
86
|
+
const indexRecordsRaw: IndexRecord[] = (indexPayload as IndexPayload).records ?? [];
|
|
87
|
+
const siteConfigRaw: SiteConfigPayload = siteConfigPayload as SiteConfigPayload;
|
|
100
88
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const parsed = JSON.parse(raw) as FullPayload;
|
|
107
|
-
return (parsed.records ?? []) as Resource[];
|
|
108
|
-
} catch {
|
|
109
|
-
return [];
|
|
110
|
-
}
|
|
111
|
-
},
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const indexRecordsRaw: IndexRecord[] = loadGenerated(
|
|
115
|
-
"records.index.json",
|
|
116
|
-
(raw) => {
|
|
117
|
-
if (!raw) return [];
|
|
118
|
-
try {
|
|
119
|
-
const parsed = JSON.parse(raw) as IndexPayload;
|
|
120
|
-
return (parsed.records ?? []) as IndexRecord[];
|
|
121
|
-
} catch {
|
|
122
|
-
return [];
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const siteConfigRaw: SiteConfigPayload = loadGenerated(
|
|
128
|
-
"site-config.json",
|
|
129
|
-
(raw) => {
|
|
130
|
-
if (!raw) return {};
|
|
131
|
-
try {
|
|
132
|
-
return JSON.parse(raw) as SiteConfigPayload;
|
|
133
|
-
} catch {
|
|
134
|
-
return {};
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Full records (all visibility). Use this for the detail page,
|
|
141
|
-
* where you need `content`, `bestFor`, `whyListed`, `caveats`,
|
|
142
|
-
* the full `github.repository` block, etc.
|
|
143
|
-
*/
|
|
89
|
+
/** Full records (every record, all visibility). Use this for the
|
|
90
|
+
* detail page (where you need `content`, `bestFor`, `whyListed`,
|
|
91
|
+
* `caveats`, the full `github.repository` block, ...) and for
|
|
92
|
+
* the V0-published alias page at `/apps/[recordSlug]` that
|
|
93
|
+
* enumerates all records for `getStaticPaths`. */
|
|
144
94
|
export const fullRecords: Resource[] = fullRecordsRaw;
|
|
145
95
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
* page and any home-page sectioning, where you only need the
|
|
149
|
-
* slim search-index fields.
|
|
150
|
-
*/
|
|
151
|
-
export const records: IndexRecord[] =
|
|
152
|
-
indexRecordsRaw.length > 0 ? indexRecordsRaw : [];
|
|
96
|
+
/** Index-payload records (visible-only slim shape). */
|
|
97
|
+
export const records: IndexRecord[] = indexRecordsRaw;
|
|
153
98
|
|
|
154
|
-
/**
|
|
99
|
+
/** Resource-kind records — slim shape. */
|
|
100
|
+
/** Project-kind records — slim shape, ready for list pages. */
|
|
155
101
|
export const projects = records.filter(
|
|
156
102
|
(r): r is IndexProjectRecord => r.kind === "project",
|
|
157
103
|
);
|
|
158
104
|
|
|
159
|
-
/** Resource-kind records — slim shape. */
|
|
160
105
|
export const resources = records.filter(
|
|
161
106
|
(r): r is IndexResourceRecord => r.kind === "resource",
|
|
162
107
|
);
|
|
@@ -278,3 +223,96 @@ export const items: IndexRecord[] = (() => {
|
|
|
278
223
|
export const fullItems: Resource[] = (() => {
|
|
279
224
|
return fullRecords.filter((r) => r.kind === blueprintKind);
|
|
280
225
|
})();
|
|
226
|
+
|
|
227
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
228
|
+
// Markdown content rendering (sanitized)
|
|
229
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
230
|
+
//
|
|
231
|
+
// `record.content` is a path to a markdown file under
|
|
232
|
+
// `content/records/<slug>.md` (see the project schema in
|
|
233
|
+
// @grove-dev/core). The previous page-level implementation
|
|
234
|
+
// imported `node:fs`, called `marked.parse` on the result, and
|
|
235
|
+
// inlined the raw HTML through `set:html` — a live XSS footgun
|
|
236
|
+
// the moment a record body is added. This module owns the read
|
|
237
|
+
// + render + sanitize pipeline at build time:
|
|
238
|
+
//
|
|
239
|
+
// 1. Resolve the path relative to this module's URL (or
|
|
240
|
+
// `process.cwd` for tool-driven runs).
|
|
241
|
+
// 2. Read the file (gracefully absent → `null`).
|
|
242
|
+
// 3. `marked.parse` to HTML.
|
|
243
|
+
// 4. `sanitize-html` with a conservative allowlist that
|
|
244
|
+
// matches the elements the `grove-prose` CSS actually
|
|
245
|
+
// styles (h1-h4, p, ul/ol/li, pre/code, blockquote, a).
|
|
246
|
+
// Links are restricted to safe schemes and external links
|
|
247
|
+
// are hardened to `rel="noopener noreferrer" target="_blank"`.
|
|
248
|
+
// javascript: / data: URIs are blocked; event handlers,
|
|
249
|
+
// iframes, and scripts are stripped.
|
|
250
|
+
//
|
|
251
|
+
// The result is computed once at module load (per record) and
|
|
252
|
+
// memoized in `contentHtmlBySlug`. Pages call
|
|
253
|
+
// `getContentHtml(recordSlug)` to receive the sanitized HTML or
|
|
254
|
+
// `null` if the record has no `content` field / the file is
|
|
255
|
+
// missing. No page module needs to import `node:fs` anymore.
|
|
256
|
+
|
|
257
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
258
|
+
function resolveContentPath(contentPath: string): string {
|
|
259
|
+
const candidates = [
|
|
260
|
+
resolve(here, "..", "..", contentPath),
|
|
261
|
+
resolve(here, "..", "..", "..", contentPath),
|
|
262
|
+
resolve(process.cwd(), contentPath),
|
|
263
|
+
];
|
|
264
|
+
return candidates.find((p) => existsSync(p)) ?? "";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const contentHtmlBySlug = new Map<string, string>();
|
|
268
|
+
for (const r of fullRecords) {
|
|
269
|
+
if (r.kind !== "project") continue;
|
|
270
|
+
const projectRecord = r as ProjectRecord;
|
|
271
|
+
if (!projectRecord.content) continue;
|
|
272
|
+
const path = resolveContentPath(projectRecord.content);
|
|
273
|
+
if (!path) continue;
|
|
274
|
+
try {
|
|
275
|
+
const text = readFileSync(path, "utf8");
|
|
276
|
+
// `async: false` keeps the call synchronous so we can populate
|
|
277
|
+
// the map at module-load time. (marked v18 defaults to
|
|
278
|
+
// Promise-returning; v9-17 also support this flag.)
|
|
279
|
+
const rawHtml = marked.parse(text, { async: false }) as string;
|
|
280
|
+
const safeHtml = sanitizeHtml(rawHtml, {
|
|
281
|
+
allowedTags: [
|
|
282
|
+
"h1", "h2", "h3", "h4",
|
|
283
|
+
"p", "br", "hr",
|
|
284
|
+
"ul", "ol", "li",
|
|
285
|
+
"strong", "em", "b", "i", "u", "s", "del",
|
|
286
|
+
"a", "code", "pre", "blockquote",
|
|
287
|
+
],
|
|
288
|
+
allowedAttributes: {
|
|
289
|
+
a: ["href", "title", "rel", "target"],
|
|
290
|
+
},
|
|
291
|
+
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
292
|
+
allowedSchemesByTag: { a: ["http", "https", "mailto", "tel"] },
|
|
293
|
+
transformTags: {
|
|
294
|
+
a: sanitizeHtml.simpleTransform("a", {
|
|
295
|
+
rel: "noopener noreferrer",
|
|
296
|
+
target: "_blank",
|
|
297
|
+
}, true),
|
|
298
|
+
},
|
|
299
|
+
disallowedTagsMode: "discard",
|
|
300
|
+
});
|
|
301
|
+
contentHtmlBySlug.set(r.slug, safeHtml);
|
|
302
|
+
} catch {
|
|
303
|
+
// Missing / unreadable / parse-failed content: skip the record
|
|
304
|
+
// rather than render broken HTML. The page treats `null` as
|
|
305
|
+
// "no Notes section".
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Pre-sanitized HTML for a record's `content` markdown body, or
|
|
311
|
+
* `null` if the record has no `content` field / the file is
|
|
312
|
+
* missing / parse failed. Safe to feed straight into
|
|
313
|
+
* `set:html` — the render and sanitize steps already ran at
|
|
314
|
+
* module load.
|
|
315
|
+
*/
|
|
316
|
+
export function getContentHtml(slug: string): string | null {
|
|
317
|
+
return contentHtmlBySlug.get(slug) ?? null;
|
|
318
|
+
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
resourceBySlug,
|
|
28
28
|
entityBySlug,
|
|
29
29
|
indexSlug,
|
|
30
|
+
getContentHtml,
|
|
30
31
|
} from "../../data/records";
|
|
31
32
|
import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
|
|
32
33
|
import Icon from "@grove-dev/astro/components/Icon.astro";
|
|
@@ -35,24 +36,17 @@ import CurationGrid from "@grove-dev/astro/components/CurationGrid.astro";
|
|
|
35
36
|
import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl } from "@grove-dev/astro";
|
|
36
37
|
import { formatRelative, formatStars } from "@grove-dev/astro";
|
|
37
38
|
import { statusDisplay, prettySlug, labelDisplay } from "@grove-dev/astro";
|
|
38
|
-
import {
|
|
39
|
-
import { fileURLToPath } from "node:url";
|
|
40
|
-
import { dirname, resolve } from "node:path";
|
|
39
|
+
import type { LabelId } from "@grove-dev/astro";
|
|
41
40
|
import type { ProjectRecord } from "@grove-dev/core";
|
|
42
|
-
import { marked } from "marked";
|
|
43
41
|
|
|
44
|
-
export
|
|
42
|
+
export function getStaticPaths() {
|
|
45
43
|
// Walk every record and emit a path for it. The directory slug
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
dirSlug = raw.default?.blueprintConfig?.routeSlug ?? "projects";
|
|
53
|
-
} catch {
|
|
54
|
-
/* not present */
|
|
55
|
-
}
|
|
44
|
+
// is read from the static site-config import at the top of this
|
|
45
|
+
// file (Vite resolves the JSON at build time). This template
|
|
46
|
+
// ships with the `project-directory` blueprint, so the slug is
|
|
47
|
+
// always `projects`; swapping in a different blueprint means
|
|
48
|
+
// changing the blueprint config that drives this value.
|
|
49
|
+
const dirSlug = siteConfig.blueprintConfig?.routeSlug ?? "projects";
|
|
56
50
|
return fullRecords.map((record) => ({
|
|
57
51
|
params: { slug: dirSlug, recordSlug: record.slug },
|
|
58
52
|
}));
|
|
@@ -107,25 +101,27 @@ const tags = record.tags ?? [];
|
|
|
107
101
|
const healthStatus = proj?.health?.status;
|
|
108
102
|
const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
|
|
109
103
|
|
|
104
|
+
// ── Badge class lookup ────────────────────────────────────────────
|
|
105
|
+
// The previous code interpolated `LabelId` straight into the class
|
|
106
|
+
// string (`grove-badge-{l}`), which Astro renders as a literal class
|
|
107
|
+
// name — every badge became `grove-badge-{l}` regardless of value.
|
|
108
|
+
// Map each known id to its stable CSS class in the package
|
|
109
|
+
// stylesheet. Unknown ids (e.g. legacy "featured") fall back to the
|
|
110
|
+
// neutral `.grove-badge` look instead of producing broken classes.
|
|
111
|
+
const labelClassMap: Record<LabelId, string> = {
|
|
112
|
+
new: "grove-badge-new",
|
|
113
|
+
hot: "grove-badge-hot",
|
|
114
|
+
mature: "grove-badge-mature",
|
|
115
|
+
featured: "grove-badge",
|
|
116
|
+
};
|
|
117
|
+
|
|
110
118
|
// ── Optional content markdown (record.content is a path under
|
|
111
119
|
// content/records/<slug>.md) ────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
resolve(process.cwd(), proj.content),
|
|
118
|
-
];
|
|
119
|
-
const contentPath = candidates.find((p) => existsSync(p));
|
|
120
|
-
if (contentPath) {
|
|
121
|
-
try {
|
|
122
|
-
const text = readFileSync(contentPath, "utf8");
|
|
123
|
-
contentHtml = await marked.parse(text);
|
|
124
|
-
} catch {
|
|
125
|
-
contentHtml = null;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
120
|
+
// The read + marked.parse + sanitize-html pipeline lives in
|
|
121
|
+
// `data/records.ts` and runs once at module load. The page just
|
|
122
|
+
// looks up the pre-sanitized HTML by record slug; no node:fs
|
|
123
|
+
// import needed here.
|
|
124
|
+
const contentHtml: string | null = isProject ? getContentHtml(recordSlug) : null;
|
|
129
125
|
|
|
130
126
|
// ── Detail-page decision-signal data (5 sections) ────────────
|
|
131
127
|
// Hoist out of the template body so the JSX stays free of
|
|
@@ -296,7 +292,7 @@ if (isProject && proj) {
|
|
|
296
292
|
</span>
|
|
297
293
|
)}
|
|
298
294
|
{curationLabels.map((l) => (
|
|
299
|
-
<span class=
|
|
295
|
+
<span class={`grove-badge ${labelClassMap[l as LabelId] ?? ""}`} title={`Curated: ${labelDisplay(l) ?? l}`}>
|
|
300
296
|
{labelDisplay(l) ?? prettySlug(l)}
|
|
301
297
|
</span>
|
|
302
298
|
))}
|