@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.
Files changed (38) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +44 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/display.js +4 -4
  6. package/dist/lib/display.js.map +1 -1
  7. package/dist/lib/repo.d.ts.map +1 -1
  8. package/dist/lib/repo.js +3 -4
  9. package/dist/lib/repo.js.map +1 -1
  10. package/dist/lib/search.d.ts +2 -2
  11. package/dist/lib/search.d.ts.map +1 -1
  12. package/dist/lib/search.js +17 -6
  13. package/dist/lib/search.js.map +1 -1
  14. package/dist/theme.test.d.ts +2 -0
  15. package/dist/theme.test.d.ts.map +1 -0
  16. package/dist/theme.test.js +41 -0
  17. package/dist/theme.test.js.map +1 -0
  18. package/package.json +1 -1
  19. package/src/components/FilterGroupMenu.astro +3 -1
  20. package/src/components/Hero.astro +3 -3
  21. package/src/components/Icon.astro +4 -4
  22. package/src/components/IndexRow.astro +15 -2
  23. package/src/index.ts +52 -1
  24. package/src/layouts/BaseLayout.astro +12 -11
  25. package/src/layouts/Seo.astro +12 -4
  26. package/src/lib/display.ts +4 -4
  27. package/src/lib/repo.ts +3 -4
  28. package/src/lib/search.ts +18 -8
  29. package/src/styles.css +184 -69
  30. package/src/theme.test.ts +52 -0
  31. package/templates/default/package.json +2 -1
  32. package/templates/default/public/llms-full.txt +1 -1
  33. package/templates/default/public/sitemap.xml +10 -10
  34. package/templates/default/src/data/records.ts +115 -77
  35. package/templates/default/src/pages/[slug]/[recordSlug].astro +29 -33
  36. package/templates/default/src/pages/[slug]/index.astro +20 -296
  37. package/templates/default/src/styles/global.css +14 -39
  38. 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
- * When the JSON files are missing (e.g. before `grove generate`
28
- * runs, or in a fresh scaffold with no records) this module falls
29
- * back to empty arrays so the Astro build still succeeds every
30
- * page must render a graceful "no records yet" state.
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
- function loadGenerated<T>(filename: string, parser: (raw: string) => T): T {
80
- // The Astro build resolves JSON imports from `src/data/*.ts`, but
81
- // we want to be defensive about the order of operations: a fresh
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
- const fullRecordsRaw: Resource[] = loadGenerated(
102
- "records.full.json",
103
- (raw) => {
104
- if (!raw) return [];
105
- try {
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
- * Index-payload records (visible only). Use this for the list
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
- /** Project-kind records only — slim shape, ready for list pages. */
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 { readFileSync, existsSync } from "node:fs";
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 async function getStaticPaths() {
42
+ export function getStaticPaths() {
45
43
  // Walk every record and emit a path for it. The directory slug
46
- // (`projects` / `resources` / `entities`) is taken from the
47
- // blueprint config so a `resource-hub` blueprint produces
48
- // `/resources/<slug>` URLs without any per-blueprint fork.
49
- let dirSlug = "projects";
50
- try {
51
- const raw = await import("../../../data/generated/site-config.json");
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
- let contentHtml: string | null = null;
113
- if (isProject && proj?.content) {
114
- const here = dirname(fileURLToPath(import.meta.url));
115
- const candidates = [
116
- resolve(here, "..", "..", "..", proj.content),
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="grove-badge grove-badge-{l}" title={`Curated: ${labelDisplay(l) ?? l}`}>
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
  ))}