@grove-dev/astro 0.2.20 → 0.3.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/dist/template-routes.test.d.ts +2 -0
  3. package/dist/template-routes.test.d.ts.map +1 -0
  4. package/dist/template-routes.test.js +63 -0
  5. package/dist/template-routes.test.js.map +1 -0
  6. package/dist/theme.test.js +5 -0
  7. package/dist/theme.test.js.map +1 -1
  8. package/package.json +4 -4
  9. package/src/components/Icon.astro +52 -22
  10. package/src/template-routes.test.ts +103 -0
  11. package/src/theme.test.ts +9 -0
  12. package/templates/default/.github/workflows/build.yml +1 -3
  13. package/templates/default/.github/workflows/cleanup-stale-records.yml +1 -3
  14. package/templates/default/.github/workflows/daily-refresh.yml +1 -3
  15. package/templates/default/.github/workflows/sync-contributors.yml +2 -4
  16. package/templates/default/.github/workflows/sync-github-metadata.yml +1 -3
  17. package/templates/default/.github/workflows/validate-data.yml +1 -3
  18. package/templates/default/grove.config.ts +1 -1
  19. package/templates/default/package.json +9 -4
  20. package/templates/default/public/llms-full.txt +63 -186
  21. package/templates/default/public/llms.txt +6 -107
  22. package/templates/default/public/sitemap.xml +10 -10
  23. package/templates/default/src/data/records.ts +80 -2
  24. package/templates/default/src/pages/[slug]/[recordSlug].astro +9 -7
  25. package/templates/default/src/pages/[slug]/index.astro +215 -11
  26. package/templates/default/src/pages/about.astro +8 -1
  27. package/templates/default/src/pages/apps/[recordSlug].astro +2 -1
  28. package/templates/default/src/pages/index.astro +11 -3
  29. package/templates/default/src/pages/sitemap.xml.ts +3 -3
  30. package/templates/default/src/pages/submit.astro +5 -5
  31. package/templates/default/tsconfig.json +9 -0
  32. package/templates/default/scripts/build-llms.mjs +0 -123
  33. package/templates/default/scripts/build-records-json.mjs +0 -27
  34. package/templates/default/scripts/build-sitemap.mjs +0 -20
  35. package/templates/default/scripts/cleanup-stale-records.mjs +0 -20
  36. package/templates/default/scripts/enrich-github-metadata.mjs +0 -99
  37. package/templates/default/scripts/fetch-icons.mjs +0 -137
  38. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +0 -86
  39. package/templates/default/scripts/parse-legacy-readme.mjs +0 -59
  40. package/templates/default/scripts/refresh-records-activity.mjs +0 -24
  41. package/templates/default/scripts/repair-license-format.mjs +0 -72
  42. package/templates/default/scripts/report-cleanup-candidates.mjs +0 -21
  43. package/templates/default/scripts/seed-from-github.mjs +0 -62
  44. package/templates/default/scripts/sync-contributors.mjs +0 -145
  45. package/templates/default/scripts/sync-github-metadata.mjs +0 -28
  46. package/templates/default/scripts/validate-records.mjs +0 -27
@@ -1,206 +1,83 @@
1
- # example.com — full directory
1
+ # Grove Directory — full directory
2
2
 
3
- Generated: 2026-06-22T16:19:40.464Z
4
- Records: 8
3
+ > Generated 2026-07-01T16:50:32.236Z from 8 records.
4
+ > Source: https://example.com/projects · Regenerate with `grove generate`.
5
5
 
6
- ---
6
+ Each section below mirrors one record detail page.
7
7
 
8
- ## Coolify
8
+ ## Index
9
9
 
10
- Open-source, self-hostable Heroku / Netlify / Vercel alternative — deploy apps and databases from a single dashboard.
11
-
12
- - **URL**: https://example.com/coolify
13
- - **Repository**: https://github.com/coollabsio/coolify
14
- - **Stack**: SvelteKit
15
- - **Platforms**: linux, self-host
16
- - **Category**: DevOps
17
- - **Tags**: self-host, paas, deployment, svelte
18
- - **Stars**: 0
19
- - **Last commit**: —
20
-
21
- **Best for:**
22
- - Self-hosted PaaS
23
- - VPS deployments
24
- - Docker-based stacks
10
+ - [Coolify](#coolify) — DevOps · SvelteKit · 0★ — Open-source, self-hostable Heroku / Netlify / Vercel alternative — deploy apps and databases from a single dashboard.
11
+ - [Gitea](#gitea) — DevOps · Go · 0★ — Lightweight, self-hosted Git service — painless, single-binary, low-resource alternative to GitHub.
12
+ - [Hoppscotch](#hoppscotch) — Web · Vue · 0★ — Open-source API development ecosystem — design, test, and debug APIs faster from a single interface.
13
+ - [InvenTree](#inventree) — Mobile · Flutter · 0★ — Open-source inventory management system with parts, stock, and purchase orders.
14
+ - [Label Studio](#label-studio) — AI/ML · Python · 0★ — Open-source data labeling platform — annotate images, text, audio, video, and time series for ML training.
15
+ - [Logseq](#logseq) — Web · ClojureScript · 0★ — Privacy-first, open-source knowledge base that works on top of plain text Markdown and Org-mode files.
16
+ - [PrivateGPT](#private-gpt) — AI/ML · Python · 0★ — Interact privately with your documents using LLMs — 100% private, no data leaves your execution environment.
17
+ - [Wakapi](#wakapi) — Mobile · Kotlin · 0★ — Open-source time tracker for developers — coding stats, language breakdowns, and project-level metrics.
25
18
 
26
- **Why listed:**
27
- - One-click app + DB deploys
28
- - Active dev
29
- - Clear upgrade path from manual Docker
19
+ ## Projects
30
20
 
31
- ---
32
-
33
- ## Gitea
21
+ ### Coolify
22
+ Open-source, self-hostable Heroku / Netlify / Vercel alternative — deploy apps and databases from a single dashboard.
23
+ - slug: coolify
24
+ - category: DevOps
25
+ - stack: SvelteKit
26
+ - stars: 0
27
+ - url: https://example.com/projects/coolify
34
28
 
29
+ ### Gitea
35
30
  Lightweight, self-hosted Git service — painless, single-binary, low-resource alternative to GitHub.
31
+ - slug: gitea
32
+ - category: DevOps
33
+ - stack: Go
34
+ - stars: 0
35
+ - url: https://example.com/projects/gitea
36
36
 
37
- - **URL**: https://example.com/gitea
38
- - **Repository**: https://github.com/go-gitea/gitea
39
- - **Stack**: Go
40
- - **Platforms**: linux, self-host, docker
41
- - **Category**: DevOps
42
- - **Tags**: git, forge, self-host, go
43
- - **Stars**: 0
44
- - **Last commit**: —
45
-
46
- **Best for:**
47
- - Self-hosted Git hosting
48
- - Lightweight CI/CD integration
49
- - Internal team forges
50
-
51
- **Why listed:**
52
- - Single binary, easy install
53
- - Mature, stable releases
54
- - Active community
55
-
56
- ---
57
-
58
- ## Hoppscotch
59
-
37
+ ### Hoppscotch
60
38
  Open-source API development ecosystem — design, test, and debug APIs faster from a single interface.
39
+ - slug: hoppscotch
40
+ - category: Web
41
+ - stack: Vue
42
+ - stars: 0
43
+ - url: https://example.com/projects/hoppscotch
61
44
 
62
- - **URL**: https://example.com/hoppscotch
63
- - **Repository**: https://github.com/hoppscotch/hoppscotch
64
- - **Stack**: Vue
65
- - **Platforms**: web, desktop, self-host
66
- - **Category**: Web
67
- - **Tags**: api, testing, developer-tools, vue
68
- - **Stars**: 0
69
- - **Last commit**: —
70
-
71
- **Best for:**
72
- - API exploration
73
- - REST/GraphQL/WebSocket testing
74
- - Self-hosted team workflows
75
-
76
- **Why listed:**
77
- - Lightweight, fast UI
78
- - PWA + desktop builds
79
- - Open-source Postman/Insomnia alternative
80
-
81
- ---
82
-
83
- ## InvenTree
84
-
45
+ ### InvenTree
85
46
  Open-source inventory management system with parts, stock, and purchase orders.
47
+ - slug: inventree
48
+ - category: Mobile
49
+ - stack: Flutter
50
+ - stars: 0
51
+ - url: https://example.com/projects/inventree
86
52
 
87
- - **URL**: https://example.com/inventree
88
- - **Repository**: https://github.com/inventree/InvenTree
89
- - **Stack**: Flutter
90
- - **Platforms**: ios, android, web
91
- - **Category**: Mobile
92
- - **Tags**: inventory, stock, mobile, dart, flutter
93
- - **Stars**: 0
94
- - **Last commit**: —
95
-
96
- **Best for:**
97
- - Inventory tracking
98
- - Manufacturing
99
- - Hardware projects
100
-
101
- **Why listed:**
102
- - Production-grade mobile + web stack
103
- - Strong community
104
- - Cleanly factored Dart codebase
105
-
106
- ---
107
-
108
- ## Label Studio
109
-
53
+ ### Label Studio
110
54
  Open-source data labeling platform — annotate images, text, audio, video, and time series for ML training.
55
+ - slug: label-studio
56
+ - category: AI/ML
57
+ - stack: Python
58
+ - stars: 0
59
+ - url: https://example.com/projects/label-studio
111
60
 
112
- - **URL**: https://example.com/label-studio
113
- - **Repository**: https://github.com/HumanSignal/label-studio
114
- - **Stack**: Python
115
- - **Platforms**: web, self-host
116
- - **Category**: AI/ML
117
- - **Tags**: labeling, ml-ops, annotation, python
118
- - **Stars**: 0
119
- - **Last commit**: —
120
-
121
- **Best for:**
122
- - ML dataset labeling
123
- - Multi-modal annotation
124
- - Active learning pipelines
125
-
126
- **Why listed:**
127
- - Mature, production-grade
128
- - Strong ML backend
129
- - Active commercial + community backing
130
-
131
- ---
132
-
133
- ## Logseq
134
-
61
+ ### Logseq
135
62
  Privacy-first, open-source knowledge base that works on top of plain text Markdown and Org-mode files.
63
+ - slug: logseq
64
+ - category: Web
65
+ - stack: ClojureScript
66
+ - stars: 0
67
+ - url: https://example.com/projects/logseq
136
68
 
137
- - **URL**: https://example.com/logseq
138
- - **Repository**: https://github.com/logseq/logseq
139
- - **Stack**: ClojureScript
140
- - **Platforms**: web, desktop
141
- - **Category**: Web
142
- - **Tags**: notes, knowledge-base, productivity, clojure
143
- - **Stars**: 0
144
- - **Last commit**: —
145
-
146
- **Best for:**
147
- - PKM (personal knowledge management)
148
- - Local-first note taking
149
- - Graph-based outliner
150
-
151
- **Why listed:**
152
- - Local-first with sync
153
- - Active community
154
- - Strong plugin ecosystem
155
-
156
- ---
157
-
158
- ## PrivateGPT
159
-
69
+ ### PrivateGPT
160
70
  Interact privately with your documents using LLMs — 100% private, no data leaves your execution environment.
71
+ - slug: private-gpt
72
+ - category: AI/ML
73
+ - stack: Python
74
+ - stars: 0
75
+ - url: https://example.com/projects/private-gpt
161
76
 
162
- - **URL**: https://example.com/private-gpt
163
- - **Repository**: https://github.com/zylon-ai/private-gpt
164
- - **Stack**: Python
165
- - **Platforms**: linux, macos, self-host
166
- - **Category**: AI/ML
167
- - **Tags**: llm, rag, privacy, python
168
- - **Stars**: 0
169
- - **Last commit**: —
170
-
171
- **Best for:**
172
- - Private document Q&A
173
- - Local LLM experimentation
174
- - Enterprise privacy use cases
175
-
176
- **Why listed:**
177
- - Strict no-network mode
178
- - Pluggable LLM providers
179
- - Clean ingestion pipeline
180
-
181
- ---
182
-
183
- ## Wakapi
184
-
77
+ ### Wakapi
185
78
  Open-source time tracker for developers — coding stats, language breakdowns, and project-level metrics.
186
-
187
- - **URL**: https://example.com/wakapi
188
- - **Repository**: https://github.com/muety/wakapi
189
- - **Stack**: Kotlin
190
- - **Platforms**: android
191
- - **Category**: Mobile
192
- - **Tags**: time-tracking, analytics, mobile, kotlin
193
- - **Stars**: 0
194
- - **Last commit**: —
195
-
196
- **Best for:**
197
- - Personal coding stats
198
- - WakaTime alternative
199
- - Privacy-first telemetry
200
-
201
- **Why listed:**
202
- - Self-host friendly
203
- - Companion mobile app
204
- - Clean MVVM architecture
205
-
206
- ---
79
+ - slug: wakapi
80
+ - category: Mobile
81
+ - stack: Kotlin
82
+ - stars: 0
83
+ - url: https://example.com/projects/wakapi
@@ -1,112 +1,11 @@
1
1
  # Grove Directory
2
2
 
3
- Grove Directory is a curated, health-aware developer directory powered by
4
- the Grove framework. Every entry is real, runnable, and actively
5
- maintained — not an SDK, not a template, not a toy demo.
3
+ A curated, health-aware developer directory powered by Grove.
6
4
 
7
- The directory is built from plain YAML files in the consumer's
8
- repository. The build pipeline renders a static Astro site. No
9
- database, no JavaScript framework, no CDN with sticky sessions.
5
+ Directory: https://example.com/projects
6
+ Records indexed: 8
7
+ Categories: 4
10
8
 
11
- ## Main URLs
9
+ ## Usage
12
10
 
13
- - Home: https://example.com/
14
- - Browse: https://example.com/projects
15
- - About: https://example.com/about
16
- - Contributors: https://example.com/contributors
17
- - Submit a record: https://example.com/submit
18
-
19
- ## Filter views (one URL = one curated lens)
20
-
21
- - Trending: https://example.com/projects?label=hot
22
- - Recently added: https://example.com/projects?label=new
23
- - Established (mature): https://example.com/projects?label=mature
24
- - Featured: https://example.com/projects?label=featured
25
- - Good to learn: https://example.com/projects?lens=good-to-learn
26
- - Production-like: https://example.com/projects?lens=production-like
27
- - Beginner-friendly: https://example.com/projects?lens=beginner-friendly
28
- - Contribution-ready: https://example.com/projects?lens=contribution-ready
29
-
30
- ## Browse axes
31
-
32
- - By stack: https://example.com/projects (use the `stack` query param)
33
- - By category: https://example.com/projects (use the `category` query param)
34
- - By platform: https://example.com/projects (use the `platform` query param)
35
- - By license: https://example.com/projects (use the `license` query param)
36
- - By health: https://example.com/projects (use the `status` query param)
37
-
38
- ## What the directory contains
39
-
40
- - Real, runnable open-source records (projects, resources, entities)
41
- - Stack, language, and architecture at a glance
42
- - Activity, health signals, and maturity tiers
43
- - License, stars, and contribution readiness
44
- - Curated notes: best for, why listed, caveats
45
- - Filters by stack, platform, license, and status
46
-
47
- ## Data layout (for AI agents that want to ingest the catalog)
48
-
49
- - `data/records/*.yml` — human-edited source of truth (one file per record)
50
- - `data/taxonomy/categories.yml` — 16 categories (Productivity, Finance, …)
51
- - `data/taxonomy/stacks.yml` — 6 stacks (Flutter, React Native, …)
52
- - `data/taxonomy/platforms.yml` — 7 platforms (iOS, Android, …)
53
- - `data/taxonomy/distribution-channels.yml` — 11 distribution channels
54
- - `data/generated/records.full.json` — full record projection
55
- - `data/generated/records.index.json` — slim list-page projection
56
- - `data/generated/site-config.json` — blueprint + site metadata
57
-
58
- For per-record markdown (one section per record with description, repo,
59
- stack, platforms, stars, last-commit), see `/llms-full.txt`.
60
-
61
- ## Blueprint system
62
-
63
- Grove supports three blueprints out of the box:
64
-
65
- - `project-directory` (default) — `kind: project` records under
66
- `/projects/<slug>`. Use this for code-reference showcases.
67
- - `resource-hub` — `kind: resource` records (datasets, libraries,
68
- tools) under `/resources/<slug>`.
69
- - `ecosystem-map` — `kind: entity` records (companies, foundations,
70
- people) under `/entities/<slug>`.
71
-
72
- The blueprint is selected in `grove.config.ts` and propagates through
73
- to every page, route, and component — no per-blueprint forks needed.
74
-
75
- ## How the build runs
76
-
77
- ```
78
- pnpm run build:data # yml → JSON (grove generate)
79
- pnpm run build:sitemap # emit public/sitemap.xml (grove sitemap)
80
- pnpm run build:llms # emit public/llms-full.txt (committed llms.txt is preserved)
81
- astro build # render the static site
82
- ```
83
-
84
- ## Contributing
85
-
86
- Open a PR against `data/records/<slug>.yml`. The build pipeline will
87
- fail if your record doesn't validate against the blueprint schema.
88
- Read `grove.config.ts` and the `data/taxonomy/` files for the
89
- available fields and enum values.
90
-
91
- ## License
92
-
93
- MIT — the directory content, schema, and source code are all
94
- permissively licensed. The data is yours to fork, mirror, or
95
- republish; attribution to the original author is appreciated but not
96
- required.
97
-
98
- ## Contact
99
-
100
- Issues: open a ticket on the consumer repository's issue tracker.
101
- Pull requests: see `CONTRIBUTING.md` for the workflow.
102
-
103
- ## LLM-specific guidance
104
-
105
- - Per-record markdown: `/llms-full.txt` (generated, ~2,000 lines for
106
- ~150 records).
107
- - Sitemap: `/sitemap.xml` (lists every record's detail URL).
108
- - robots.txt: explicitly allows GPTBot, ChatGPT-User, ClaudeBot,
109
- Claude-Web, anthropic-ai, PerplexityBot, Google-Extended,
110
- Applebot-Extended, cohere-ai, CCBot, Amazonbot.
111
- - Schema: each detail page emits a `SoftwareSourceCode` (or
112
- `Dataset` / `Organization`) JSON-LD block for AI crawlers.
11
+ Use /llms-full.txt for record-level details. Prefer detail pages for citations.
@@ -2,61 +2,61 @@
2
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
3
  <url>
4
4
  <loc>https://example.com/</loc>
5
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
5
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
6
6
  <changefreq>daily</changefreq>
7
7
  <priority>1.0</priority>
8
8
  </url>
9
9
  <url>
10
10
  <loc>https://example.com/projects</loc>
11
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
11
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
12
12
  <changefreq>daily</changefreq>
13
13
  <priority>0.9</priority>
14
14
  </url>
15
15
  <url>
16
16
  <loc>https://example.com/projects/coolify</loc>
17
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
17
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
18
18
  <changefreq>weekly</changefreq>
19
19
  <priority>0.7</priority>
20
20
  </url>
21
21
  <url>
22
22
  <loc>https://example.com/projects/gitea</loc>
23
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
23
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
24
24
  <changefreq>weekly</changefreq>
25
25
  <priority>0.7</priority>
26
26
  </url>
27
27
  <url>
28
28
  <loc>https://example.com/projects/hoppscotch</loc>
29
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
29
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
30
30
  <changefreq>weekly</changefreq>
31
31
  <priority>0.7</priority>
32
32
  </url>
33
33
  <url>
34
34
  <loc>https://example.com/projects/inventree</loc>
35
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
35
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
36
36
  <changefreq>weekly</changefreq>
37
37
  <priority>0.7</priority>
38
38
  </url>
39
39
  <url>
40
40
  <loc>https://example.com/projects/label-studio</loc>
41
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
41
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
42
42
  <changefreq>weekly</changefreq>
43
43
  <priority>0.7</priority>
44
44
  </url>
45
45
  <url>
46
46
  <loc>https://example.com/projects/logseq</loc>
47
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
47
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
48
48
  <changefreq>weekly</changefreq>
49
49
  <priority>0.7</priority>
50
50
  </url>
51
51
  <url>
52
52
  <loc>https://example.com/projects/private-gpt</loc>
53
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
53
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
54
54
  <changefreq>weekly</changefreq>
55
55
  <priority>0.7</priority>
56
56
  </url>
57
57
  <url>
58
58
  <loc>https://example.com/projects/wakapi</loc>
59
- <lastmod>2026-06-22T16:19:39.853Z</lastmod>
59
+ <lastmod>2026-07-01T16:50:32.236Z</lastmod>
60
60
  <changefreq>weekly</changefreq>
61
61
  <priority>0.7</priority>
62
62
  </url>
@@ -38,6 +38,7 @@ import { fileURLToPath } from "node:url";
38
38
  import { dirname, resolve } from "node:path";
39
39
  import { marked } from "marked";
40
40
  import sanitizeHtml from "sanitize-html";
41
+ import { prettySlug } from "@grove-dev/astro";
41
42
  import type {
42
43
  ProjectRecord,
43
44
  ResourceRecord,
@@ -79,12 +80,39 @@ interface SiteConfigPayload {
79
80
  labelSingular?: string;
80
81
  labelPlural?: string;
81
82
  };
83
+ taxonomy?: Partial<
84
+ Record<
85
+ "categories" | "stacks" | "platforms" | "distributionChannels",
86
+ Array<{ id: string; name: string }>
87
+ >
88
+ >;
82
89
  name?: string;
83
90
  }
84
91
 
85
- const fullRecordsRaw: Resource[] = (fullPayload as FullPayload).records ?? [];
86
- const indexRecordsRaw: IndexRecord[] = (indexPayload as IndexPayload).records ?? [];
92
+ const fullRecordsRaw: Resource[] =
93
+ (fullPayload as unknown as FullPayload).records ?? [];
94
+ const indexRecordsRaw: IndexRecord[] =
95
+ (indexPayload as unknown as IndexPayload).records ?? [];
87
96
  const siteConfigRaw: SiteConfigPayload = siteConfigPayload as SiteConfigPayload;
97
+ const taxonomyMaps = Object.fromEntries(
98
+ Object.entries(siteConfigRaw.taxonomy ?? {}).map(([kind, entries]) => [
99
+ kind,
100
+ new Map((entries ?? []).map((entry) => [entry.id, entry.name])),
101
+ ]),
102
+ ) as Partial<
103
+ Record<
104
+ "categories" | "stacks" | "platforms" | "distributionChannels",
105
+ Map<string, string>
106
+ >
107
+ >;
108
+ export const taxonomy = siteConfigRaw.taxonomy ?? {};
109
+
110
+ export function taxonomyLabel(
111
+ kind: "categories" | "stacks" | "platforms" | "distributionChannels",
112
+ id: string,
113
+ ): string {
114
+ return taxonomyMaps[kind]?.get(id) ?? prettySlug(id);
115
+ }
88
116
 
89
117
  /** Full records (every record, all visibility). Use this for the
90
118
  * detail page (where you need `content`, `bestFor`, `whyListed`,
@@ -224,6 +252,10 @@ export const fullItems: Resource[] = (() => {
224
252
  return fullRecords.filter((r) => r.kind === blueprintKind);
225
253
  })();
226
254
 
255
+ export const fullProjects: ProjectRecord[] = fullRecords.filter(
256
+ (record): record is ProjectRecord => record.kind === "project",
257
+ );
258
+
227
259
  // ──────────────────────────────────────────────────────────────────────
228
260
  // Markdown content rendering (sanitized)
229
261
  // ──────────────────────────────────────────────────────────────────────
@@ -316,3 +348,49 @@ for (const r of fullRecords) {
316
348
  export function getContentHtml(slug: string): string | null {
317
349
  return contentHtmlBySlug.get(slug) ?? null;
318
350
  }
351
+
352
+ /**
353
+ * Sanitized Markdown for a consumer-authored page under
354
+ * `content/pages/<page>.md`. Default template pages use this to
355
+ * accept custom copy while keeping Grove's layout and components.
356
+ */
357
+ export function getPageContentHtml(page: string): string | null {
358
+ const candidates = [
359
+ resolve(here, "..", "..", "content", "pages", `${page}.md`),
360
+ resolve(here, "..", "..", "..", "content", "pages", `${page}.md`),
361
+ resolve(process.cwd(), "content", "pages", `${page}.md`),
362
+ ];
363
+ const path = candidates.find((candidate) => existsSync(candidate));
364
+ if (!path) return null;
365
+
366
+ try {
367
+ const rawHtml = marked.parse(readFileSync(path, "utf8"), { async: false }) as string;
368
+ return sanitizeHtml(rawHtml, {
369
+ allowedTags: [
370
+ "h1", "h2", "h3", "h4",
371
+ "p", "br", "hr",
372
+ "ul", "ol", "li",
373
+ "strong", "em", "b", "i", "u", "s", "del",
374
+ "a", "code", "pre", "blockquote", "img",
375
+ ],
376
+ allowedAttributes: {
377
+ a: ["href", "title", "rel", "target"],
378
+ img: ["src", "alt", "title", "width", "height", "loading"],
379
+ },
380
+ allowedSchemes: ["http", "https", "mailto", "tel"],
381
+ allowedSchemesByTag: {
382
+ a: ["http", "https", "mailto", "tel"],
383
+ img: ["http", "https"],
384
+ },
385
+ transformTags: {
386
+ a: sanitizeHtml.simpleTransform("a", {
387
+ rel: "noopener noreferrer",
388
+ target: "_blank",
389
+ }, true),
390
+ },
391
+ disallowedTagsMode: "discard",
392
+ });
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
@@ -64,7 +64,7 @@ if (!record) {
64
64
  }
65
65
 
66
66
  const isProject = record.kind === "project";
67
- const proj: ProjectRecord | null = project;
67
+ const proj: ProjectRecord | null = project ?? null;
68
68
 
69
69
  const name =
70
70
  record.kind === "resource" ? (record as { title: string }).title : (record as { name: string }).name;
@@ -220,18 +220,20 @@ if (isProject && proj) {
220
220
  keywords: (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined,
221
221
  isAccessibleForFree: true,
222
222
  };
223
- } else {
223
+ } else if (entity) {
224
224
  jsonLd = {
225
225
  "@context": "https://schema.org",
226
- "@type": record.type === "person" ? "Person" : "Organization",
226
+ "@type": entity.type === "person" ? "Person" : "Organization",
227
227
  name,
228
228
  headline: name,
229
- description: record.description || undefined,
229
+ description: entity.description || undefined,
230
230
  url: homepageUrl || undefined,
231
- foundingDate: record.founded || undefined,
232
- location: record.location || undefined,
233
- keywords: (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined,
231
+ foundingDate: entity.founded || undefined,
232
+ location: entity.location || undefined,
233
+ keywords: (entity.tags ?? []).length > 0 ? (entity.tags ?? []).join(", ") : undefined,
234
234
  };
235
+ } else {
236
+ jsonLd = {};
235
237
  }
236
238
  ---
237
239