@grove-dev/astro 0.5.0-next.2 → 0.5.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.
Files changed (72) hide show
  1. package/dist/index.js +2 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/index.d.ts +0 -2
  4. package/dist/lib/index.d.ts.map +1 -1
  5. package/dist/lib/index.js +0 -2
  6. package/dist/lib/index.js.map +1 -1
  7. package/dist/ui/button.d.ts +45 -0
  8. package/dist/ui/button.d.ts.map +1 -0
  9. package/dist/ui/button.js +82 -0
  10. package/dist/ui/button.js.map +1 -0
  11. package/package.json +8 -5
  12. package/src/components/CardGrid.astro +41 -0
  13. package/src/components/CardIcon.astro +67 -0
  14. package/src/components/CategoryGrid.astro +20 -5
  15. package/src/components/CollectionCard.astro +56 -0
  16. package/src/components/CollectionIndex.astro +21 -26
  17. package/src/components/CollectionPage.astro +36 -13
  18. package/src/components/CollectionRow.astro +62 -192
  19. package/src/components/CollectionTeaser.astro +4 -0
  20. package/src/components/ContributorsGrid.astro +4 -1
  21. package/src/components/DirectoryIndexClient.astro +127 -30
  22. package/src/components/EditorialSummary.astro +6 -5
  23. package/src/components/FilterGroupMenu.astro +24 -10
  24. package/src/components/FilterOptions.astro +6 -1
  25. package/src/components/FinalCta.astro +5 -3
  26. package/src/components/Hero.astro +85 -139
  27. package/src/components/IndexRow.astro +36 -69
  28. package/src/components/LanguageBreakdown.astro +5 -1
  29. package/src/components/OriginalCollection.astro +3 -2
  30. package/src/components/Pagination.astro +7 -20
  31. package/src/components/ProjectCard.astro +345 -0
  32. package/src/components/RecordHeader.astro +111 -82
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +68 -20
  35. package/src/components/RefinePanel.astro +41 -62
  36. package/src/components/SmartLensTabs.astro +3 -7
  37. package/src/components/StackGrid.astro +26 -6
  38. package/src/components/SubmissionClient.astro +140 -63
  39. package/src/components/TableOfContents.astro +31 -5
  40. package/src/components/WhyThisExists.astro +1 -1
  41. package/src/index.ts +2 -2
  42. package/src/layouts/BaseLayout.astro +79 -9
  43. package/src/layouts/Header.astro +5 -4
  44. package/src/layouts/SectionHeader.astro +3 -2
  45. package/src/layouts/Seo.astro +28 -17
  46. package/src/layouts/ThemeToggle.astro +23 -6
  47. package/src/lib/index.ts +0 -2
  48. package/src/server/collections.ts +11 -0
  49. package/src/server/contrast.test.ts +82 -0
  50. package/src/server/contrast.ts +117 -0
  51. package/src/server/directory.test.ts +177 -0
  52. package/src/server/directory.ts +328 -206
  53. package/src/server/github-repo.test.ts +88 -0
  54. package/src/server/github-repo.ts +104 -0
  55. package/src/server/index.ts +4 -3
  56. package/src/server/models-home.test.ts +101 -0
  57. package/src/server/models.test.ts +135 -0
  58. package/src/server/models.ts +170 -42
  59. package/src/styles.css +128 -16
  60. package/dist/lib/scores.d.ts +0 -2
  61. package/dist/lib/scores.d.ts.map +0 -1
  62. package/dist/lib/scores.js +0 -2
  63. package/dist/lib/scores.js.map +0 -1
  64. package/src/components/CurationGrid.astro +0 -41
  65. package/src/components/DecisionRow.astro +0 -89
  66. package/src/components/ExploreByCategory.astro +0 -67
  67. package/src/components/ExploreByStack.astro +0 -78
  68. package/src/components/GroveDocumentHead.astro +0 -28
  69. package/src/components/ItemCard.astro +0 -324
  70. package/src/components/MinimalAbout.astro +0 -82
  71. package/src/components/ScoreBars.astro +0 -102
  72. package/src/lib/scores.ts +0 -1
@@ -30,21 +30,17 @@
30
30
  * resolution error, which is the correct signal — a build that
31
31
  * silently renders an empty directory hides real config mistakes.
32
32
  */
33
- import fullPayload from "@grove/generated/records.full.json";
34
- import indexPayload from "@grove/generated/records.index.json";
35
- import siteConfigPayload from "@grove/generated/site-config.json";
36
- import { existsSync, readFileSync } from "node:fs";
37
- import { fileURLToPath } from "node:url";
38
- import { dirname, resolve } from "node:path";
39
- import { marked } from "marked";
40
- import sanitizeHtml from "sanitize-html";
41
- import { createHighlighter } from "shiki";
42
- import { prettySlug } from "../lib/display.js";
43
- import {
44
- readContentFile,
45
- stripFrontmatter,
46
- uniqueSlug,
47
- } from "@grove-dev/core";
33
+ import fullPayload from '@grove/generated/records.full.json';
34
+ import indexPayload from '@grove/generated/records.index.json';
35
+ import siteConfigPayload from '@grove/generated/site-config.json';
36
+ import { existsSync, readFileSync } from 'node:fs';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { dirname, resolve } from 'node:path';
39
+ import { marked } from 'marked';
40
+ import sanitizeHtml from 'sanitize-html';
41
+ import { getSingletonHighlighter } from 'shiki';
42
+ import { prettySlug } from '../lib/display.js';
43
+ import { headingSlug, readContentFile, stripFrontmatter, uniqueSlug } from '@grove-dev/core';
48
44
  import type {
49
45
  ProjectRecord,
50
46
  ResourceRecord,
@@ -54,7 +50,7 @@ import type {
54
50
  IndexProjectRecord,
55
51
  IndexResourceRecord,
56
52
  IndexEntityRecord,
57
- } from "@grove-dev/core";
53
+ } from '@grove-dev/core';
58
54
 
59
55
  interface FullPayload {
60
56
  schemaVersion?: number;
@@ -77,7 +73,7 @@ interface SiteConfigPayload {
77
73
  blueprint?: string;
78
74
  blueprintConfig?: {
79
75
  id?: string;
80
- kind?: "project" | "resource" | "entity";
76
+ kind?: 'project' | 'resource' | 'entity';
81
77
  routeSlug?: string;
82
78
  itemSlug?: string;
83
79
  /** V1 canonical field name for the record detail slug
@@ -88,17 +84,15 @@ interface SiteConfigPayload {
88
84
  };
89
85
  taxonomy?: Partial<
90
86
  Record<
91
- "categories" | "stacks" | "platforms" | "distributionChannels",
87
+ 'categories' | 'stacks' | 'platforms' | 'topics' | 'licenses' | 'distributionChannels',
92
88
  Array<{ id: string; name: string }>
93
89
  >
94
90
  >;
95
91
  name?: string;
96
92
  }
97
93
 
98
- const fullRecordsRaw: Resource[] =
99
- (fullPayload as unknown as FullPayload).records ?? [];
100
- const indexRecordsRaw: IndexRecord[] =
101
- (indexPayload as unknown as IndexPayload).records ?? [];
94
+ const fullRecordsRaw: Resource[] = (fullPayload as unknown as FullPayload).records ?? [];
95
+ const indexRecordsRaw: IndexRecord[] = (indexPayload as unknown as IndexPayload).records ?? [];
102
96
  const siteConfigRaw: SiteConfigPayload = siteConfigPayload as SiteConfigPayload;
103
97
  const taxonomyMaps = Object.fromEntries(
104
98
  Object.entries(siteConfigRaw.taxonomy ?? {}).map(([kind, entries]) => [
@@ -107,14 +101,14 @@ const taxonomyMaps = Object.fromEntries(
107
101
  ]),
108
102
  ) as Partial<
109
103
  Record<
110
- "categories" | "stacks" | "platforms" | "distributionChannels",
104
+ 'categories' | 'stacks' | 'platforms' | 'topics' | 'licenses' | 'distributionChannels',
111
105
  Map<string, string>
112
106
  >
113
107
  >;
114
108
  export const taxonomy = siteConfigRaw.taxonomy ?? {};
115
109
 
116
110
  export function taxonomyLabel(
117
- kind: "categories" | "stacks" | "platforms" | "distributionChannels",
111
+ kind: 'categories' | 'stacks' | 'platforms' | 'topics' | 'licenses' | 'distributionChannels',
118
112
  id: string,
119
113
  ): string {
120
114
  return taxonomyMaps[kind]?.get(id) ?? prettySlug(id);
@@ -132,18 +126,12 @@ export const records: IndexRecord[] = indexRecordsRaw;
132
126
 
133
127
  /** Resource-kind records — slim shape. */
134
128
  /** Project-kind records — slim shape, ready for list pages. */
135
- export const projects = records.filter(
136
- (r): r is IndexProjectRecord => r.kind === "project",
137
- );
129
+ export const projects = records.filter((r): r is IndexProjectRecord => r.kind === 'project');
138
130
 
139
- export const resources = records.filter(
140
- (r): r is IndexResourceRecord => r.kind === "resource",
141
- );
131
+ export const resources = records.filter((r): r is IndexResourceRecord => r.kind === 'resource');
142
132
 
143
133
  /** Entity-kind records — slim shape. */
144
- export const entities = records.filter(
145
- (r): r is IndexEntityRecord => r.kind === "entity",
146
- );
134
+ export const entities = records.filter((r): r is IndexEntityRecord => r.kind === 'entity');
147
135
 
148
136
  const bySlug = new Map(fullRecords.map((r) => [r.slug, r]));
149
137
 
@@ -161,17 +149,17 @@ export function findRecord(slug: string): Resource | undefined {
161
149
 
162
150
  export function projectBySlug(slug: string): ProjectRecord | undefined {
163
151
  const r = bySlug.get(slug);
164
- return r && r.kind === "project" ? r : undefined;
152
+ return r && r.kind === 'project' ? r : undefined;
165
153
  }
166
154
 
167
155
  export function resourceBySlug(slug: string): ResourceRecord | undefined {
168
156
  const r = bySlug.get(slug);
169
- return r && r.kind === "resource" ? r : undefined;
157
+ return r && r.kind === 'resource' ? r : undefined;
170
158
  }
171
159
 
172
160
  export function entityBySlug(slug: string): EntityRecord | undefined {
173
161
  const r = bySlug.get(slug);
174
- return r && r.kind === "entity" ? r : undefined;
162
+ return r && r.kind === 'entity' ? r : undefined;
175
163
  }
176
164
 
177
165
  // ──────────────────────────────────────────────────────────────────────
@@ -189,14 +177,11 @@ export function entityBySlug(slug: string): EntityRecord | undefined {
189
177
  // the JSON instead of a switch statement.
190
178
 
191
179
  const blueprintConfig = siteConfigRaw.blueprintConfig ?? {};
192
- const blueprintKind = (blueprintConfig.kind ?? "project") as
193
- | "project"
194
- | "resource"
195
- | "entity";
196
- const blueprintId = (blueprintConfig.id ?? "project-directory") as
197
- | "project-directory"
198
- | "resource-hub"
199
- | "ecosystem-map";
180
+ const blueprintKind = (blueprintConfig.kind ?? 'project') as 'project' | 'resource' | 'entity';
181
+ const blueprintId = (blueprintConfig.id ?? 'project-directory') as
182
+ | 'project-directory'
183
+ | 'resource-hub'
184
+ | 'ecosystem-map';
200
185
 
201
186
  /**
202
187
  * URL slug for the directory index page (e.g. `/projects/`,
@@ -205,24 +190,24 @@ const blueprintId = (blueprintConfig.id ?? "project-directory") as
205
190
  * time.
206
191
  */
207
192
  export function indexSlug(): string {
208
- return blueprintConfig.routeSlug ?? "projects";
193
+ return blueprintConfig.routeSlug ?? 'projects';
209
194
  }
210
195
 
211
196
  /** URL slug for a single record detail page (the dynamic
212
197
  * `[recordSlug]` segment). Default "project" for backwards-compat
213
198
  * with V0-published configs that exposed the field as `itemSlug`. */
214
199
  export function recordSlugConfig(): string {
215
- return blueprintConfig.recordSlug ?? blueprintConfig.itemSlug ?? "project";
200
+ return blueprintConfig.recordSlug ?? blueprintConfig.itemSlug ?? 'project';
216
201
  }
217
202
 
218
203
  /** Singular human label, e.g. "project", "resource", "entity". */
219
204
  export function itemLabel(): string {
220
- return blueprintConfig.labelSingular ?? "project";
205
+ return blueprintConfig.labelSingular ?? 'project';
221
206
  }
222
207
 
223
208
  /** Plural human label, e.g. "projects", "resources", "entities". */
224
209
  export function itemLabelPlural(): string {
225
- return blueprintConfig.labelPlural ?? "projects";
210
+ return blueprintConfig.labelPlural ?? 'projects';
226
211
  }
227
212
 
228
213
  /** Active blueprint id. */
@@ -231,7 +216,7 @@ export function blueprintIdFn(): string {
231
216
  }
232
217
 
233
218
  /** Active record kind discriminator. */
234
- export function activeKind(): "project" | "resource" | "entity" {
219
+ export function activeKind(): 'project' | 'resource' | 'entity' {
235
220
  return blueprintKind;
236
221
  }
237
222
 
@@ -243,11 +228,11 @@ export function activeKind(): "project" | "resource" | "entity" {
243
228
  */
244
229
  export const items: IndexRecord[] = (() => {
245
230
  switch (blueprintKind) {
246
- case "resource":
231
+ case 'resource':
247
232
  return resources;
248
- case "entity":
233
+ case 'entity':
249
234
  return entities;
250
- case "project":
235
+ case 'project':
251
236
  default:
252
237
  return projects;
253
238
  }
@@ -259,7 +244,7 @@ export const fullItems: Resource[] = (() => {
259
244
  })();
260
245
 
261
246
  export const fullProjects: ProjectRecord[] = fullRecords.filter(
262
- (record): record is ProjectRecord => record.kind === "project",
247
+ (record): record is ProjectRecord => record.kind === 'project',
263
248
  );
264
249
 
265
250
  // ──────────────────────────────────────────────────────────────────────
@@ -315,43 +300,98 @@ const here = dirname(fileURLToPath(import.meta.url));
315
300
  /** Wide allowlist used for `ProjectRecord.content` Markdown bodies. */
316
301
  const RECORD_BODY_ALLOWLIST = [
317
302
  // Headings — full depth.
318
- "h1", "h2", "h3", "h4", "h5", "h6",
303
+ 'h1',
304
+ 'h2',
305
+ 'h3',
306
+ 'h4',
307
+ 'h5',
308
+ 'h6',
319
309
  // Block-level content.
320
- "p", "br", "hr", "div", "blockquote",
310
+ 'p',
311
+ 'br',
312
+ 'hr',
313
+ 'div',
314
+ 'blockquote',
321
315
  // Lists.
322
- "ul", "ol", "li",
316
+ 'ul',
317
+ 'ol',
318
+ 'li',
323
319
  // Definition lists.
324
- "dl", "dt", "dd",
320
+ 'dl',
321
+ 'dt',
322
+ 'dd',
325
323
  // Tables (GFM).
326
- "table", "thead", "tbody", "tfoot", "tr", "th", "td",
327
- "caption", "colgroup", "col",
324
+ 'table',
325
+ 'thead',
326
+ 'tbody',
327
+ 'tfoot',
328
+ 'tr',
329
+ 'th',
330
+ 'td',
331
+ 'caption',
332
+ 'colgroup',
333
+ 'col',
328
334
  // Inline formatting.
329
- "strong", "em", "b", "i", "u", "s", "del", "ins",
330
- "mark", "small", "sub", "sup", "kbd", "abbr",
335
+ 'strong',
336
+ 'em',
337
+ 'b',
338
+ 'i',
339
+ 'u',
340
+ 's',
341
+ 'del',
342
+ 'ins',
343
+ 'mark',
344
+ 'small',
345
+ 'sub',
346
+ 'sup',
347
+ 'kbd',
348
+ 'abbr',
331
349
  // `<span>` is only here for Shiki's per-token wrappers inside
332
350
  // highlighted code blocks. Sanitize-html's default is to strip
333
351
  // any tag not in this list, so without `span` here the syntax
334
352
  // highlights silently disappear from fenced code.
335
- "span",
353
+ 'span',
336
354
  // Links + images.
337
- "a", "img",
355
+ 'a',
356
+ 'img',
338
357
  // Code.
339
- "code", "pre",
358
+ 'code',
359
+ 'pre',
340
360
  // Forms (task-list checkboxes only).
341
- "input", "label",
361
+ 'input',
362
+ 'label',
342
363
  // Collapsibles and semantic blocks.
343
- "details", "summary",
344
- "figure", "figcaption",
345
- "time",
364
+ 'details',
365
+ 'summary',
366
+ 'figure',
367
+ 'figcaption',
368
+ 'time',
346
369
  ];
347
370
 
348
371
  /** Narrow allowlist used for `content/pages/<page>.md`. */
349
372
  const PAGE_BODY_ALLOWLIST = [
350
- "h1", "h2", "h3", "h4",
351
- "p", "br", "hr",
352
- "ul", "ol", "li",
353
- "strong", "em", "b", "i", "u", "s", "del",
354
- "a", "code", "pre", "blockquote", "img",
373
+ 'h1',
374
+ 'h2',
375
+ 'h3',
376
+ 'h4',
377
+ 'p',
378
+ 'br',
379
+ 'hr',
380
+ 'ul',
381
+ 'ol',
382
+ 'li',
383
+ 'strong',
384
+ 'em',
385
+ 'b',
386
+ 'i',
387
+ 'u',
388
+ 's',
389
+ 'del',
390
+ 'a',
391
+ 'code',
392
+ 'pre',
393
+ 'blockquote',
394
+ 'img',
355
395
  ];
356
396
 
357
397
  /**
@@ -363,73 +403,75 @@ const PAGE_BODY_ALLOWLIST = [
363
403
  * becomes a hardened disabled checkbox for GFM task lists.
364
404
  */
365
405
  const COMMON_BODY_ATTRIBUTES = {
366
- a: ["href", "title", "rel", "target"],
367
- img: ["src", "alt", "title", "width", "height", "loading", "decoding"],
368
- th: ["scope", "colspan", "rowspan", "align"],
369
- td: ["colspan", "rowspan", "align"],
370
- col: ["span", "align"],
371
- input: ["type", "checked", "disabled"],
372
- label: ["for"],
373
- abbr: ["title"],
374
- time: ["datetime"],
375
- details: ["open"],
376
- div: ["class"], // table-wrap div the renderer injects
377
- span: ["style"], // Shiki emits inline `--shiki-light` / `--shiki-dark` CSS variables
378
- pre: ["class", "style"], // Shiki also tags the outer `<pre>` with theme classes
379
- code: ["class"],
380
- "*": ["id"],
406
+ a: ['href', 'title', 'rel', 'target'],
407
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding'],
408
+ th: ['scope', 'colspan', 'rowspan', 'align'],
409
+ td: ['colspan', 'rowspan', 'align'],
410
+ col: ['span', 'align'],
411
+ input: ['type', 'checked', 'disabled'],
412
+ label: ['for'],
413
+ abbr: ['title'],
414
+ time: ['datetime'],
415
+ details: ['open'],
416
+ div: ['class'], // table-wrap div the renderer injects
417
+ span: ['style'], // Shiki emits inline `--shiki-light` / `--shiki-dark` CSS variables
418
+ pre: ['class', 'style'], // Shiki also tags the outer `<pre>` with theme classes
419
+ code: ['class'],
420
+ '*': ['id'],
381
421
  };
382
422
 
423
+ /**
424
+ * CSS *value* patterns allowed per property in markdown authors' inline
425
+ * `style="…"` attributes. Without this filter, sanitize-html leaves
426
+ * the entire `style` string in place, which means a markdown author
427
+ * could smuggle CSS expressions (e.g. `background:url(javascript:…)`)
428
+ * into the rendered output even though no `<script>` tag is allowed.
429
+ *
430
+ * The regex is tested against the *value* part of each CSS declaration
431
+ * (`prop:value` → value), per sanitize-html's `allowedStyles` contract
432
+ * (it uses a CSS parser and matches `regularExpression.test(value)`).
433
+ *
434
+ * Allowed surface:
435
+ * - `--shiki-*` custom properties accept any value (Shiki's
436
+ * `--shiki-light` / `--shiki-dark` carry the per-token colors).
437
+ * - `color:` / `background-color:` accept only safe color values:
438
+ * hex (#rgb / #rrggbb / #rrggbbaa), rgb() / rgba(), hsl() / hsla().
439
+ *
440
+ * Anything else (positioning, layout, animation, expression(), url(),
441
+ * …) is stripped by sanitize-html because no pattern matches.
442
+ */
443
+ const SHIKI_VALUE = /.+/;
444
+ const COLOR_VALUE = /^(?:#[0-9a-f]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\))$/i;
445
+
383
446
  /** `<a>` tag normalization — open in a new tab, no opener. */
384
447
  const ANCHOR_TRANSFORM = sanitizeHtml.simpleTransform(
385
- "a",
386
- { rel: "noopener noreferrer", target: "_blank" },
448
+ 'a',
449
+ { rel: 'noopener noreferrer', target: '_blank' },
387
450
  true,
388
451
  );
389
452
 
390
453
  /** Hardens `<img>` to lazy-load + async-decode. */
391
- const IMG_TRANSFORM = (
392
- tagName: string,
393
- attribs: Record<string, string>,
394
- ) => ({
454
+ const IMG_TRANSFORM = (tagName: string, attribs: Record<string, string>) => ({
395
455
  tagName,
396
456
  attribs: {
397
457
  ...attribs,
398
- loading: attribs.loading ?? "lazy",
399
- decoding: attribs.decoding ?? "async",
458
+ loading: attribs.loading ?? 'lazy',
459
+ decoding: attribs.decoding ?? 'async',
400
460
  },
401
461
  });
402
462
 
403
463
  /** Forces `<input type="checkbox">` to be disabled so task lists
404
464
  * render read-only at runtime (no on-page state to persist). */
405
- const INPUT_TRANSFORM = (
406
- tagName: string,
407
- attribs: Record<string, string>,
408
- ) => ({
465
+ const INPUT_TRANSFORM = (tagName: string, attribs: Record<string, string>) => ({
409
466
  tagName,
410
467
  attribs: {
411
468
  ...attribs,
412
- type: "checkbox",
413
- disabled: "",
414
- ...(attribs.checked !== undefined ? { checked: "" } : {}),
469
+ type: 'checkbox',
470
+ disabled: '',
471
+ ...(attribs.checked !== undefined ? { checked: '' } : {}),
415
472
  },
416
473
  });
417
474
 
418
- /**
419
- * GitHub-style slug for a heading. Matches `core/extractToc` so the
420
- * IDs the renderer emits line up with the IDs a TOC consumer reads
421
- * out of the same body — keeping deep links in lockstep.
422
- */
423
- function headingSlug(text: string): string {
424
- return text
425
- .toLowerCase()
426
- .replace(/[‘’]/g, "")
427
- .replace(/[^a-z0-9\s-]/g, "")
428
- .replace(/\s+/g, "-")
429
- .replace(/-+/g, "-")
430
- .replace(/^-|-+$/g, "");
431
- }
432
-
433
475
  /**
434
476
  * Render a Markdown string to sanitized HTML. The renderer is
435
477
  * configured once (heading IDs + table wrap) and reused for both
@@ -439,81 +481,114 @@ function headingSlug(text: string): string {
439
481
  * caller owns where the body came from (file, in-memory cache, …).
440
482
  * `getContentHtml(slug)` is the per-record wrapper that reads the
441
483
  * file and calls this.
484
+ *
485
+ * The heading-ID counter is allocated per call (not module-level) so
486
+ * concurrent renders — or any future worker-thread path — can never
487
+ * observe each other's counters.
442
488
  */
443
489
  export function renderMarkdownToSafeHtml(
444
490
  body: string,
445
491
  options: { allowlist?: string[] } = {},
446
492
  ): string {
447
493
  const allowlist = options.allowlist ?? RECORD_BODY_ALLOWLIST;
448
- // Configure marked. GFM is on by default in v18 but set explicitly
449
- // so the intent is visible; `breaks: false` keeps single newlines
450
- // from becoming `<br>` per CommonMark.
451
- //
452
- // We register a small extension that:
453
- // 1. Adds a stable `id` to every h2–h6 so the TOC sidebar can
454
- // deep-link into the rendered body.
455
- // 2. Wraps GFM tables in `<div class="grove-prose-table-wrap">`
456
- // so the column-width overflow is contained inside a rounded
457
- // border instead of breaking the layout.
458
- marked.use({
459
- gfm: true,
460
- breaks: false,
461
- renderer: {
462
- heading({ tokens, depth }) {
463
- const inline = this.parser.parseInline(tokens);
464
- const plain = tokens
465
- .map((t: { text?: string; raw?: string }) => t.text ?? t.raw ?? "")
466
- .join("");
467
- const id = depth === 1 ? "" : ` id="${uniqueSlug(headingSlug(plain) || "section", headingIds)}"`;
468
- return `<h${depth}${id}>${inline}</h${depth}>\n`;
469
- },
470
- table(token: {
471
- header: Array<{ tokens: unknown[] }>;
472
- rows: Array<Array<{ tokens: unknown[] }>>;
473
- }) {
474
- const head = token.header
475
- .map((cell) => `<th>${this.parser.parseInline(cell.tokens)}</th>`)
476
- .join("");
477
- const body = token.rows
478
- .map((row) =>
479
- `<tr>${row
480
- .map((cell) => `<td>${this.parser.parseInline(cell.tokens)}</td>`)
481
- .join("")}</tr>`,
482
- )
483
- .join("");
484
- return `<div class="grove-prose-table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>\n`;
485
- },
486
- // Fenced code blocks: route through Shiki instead of marked's
487
- // default `<pre><code class="language-…">…</code></pre>`. The
488
- // returned HTML already wraps in `<pre>` and `<code>`; we
489
- // hand it to the sanitizer unchanged (Shiki's output is
490
- // safe by construction — no script / event handlers).
491
- code({ text, lang }) {
492
- return highlightCode(text, lang ?? "") + "\n";
493
- },
494
- },
495
- });
496
-
494
+ // Reset the heading-ID collision counter for this body so IDs are
495
+ // stable per render and never leak between records.
496
+ headingIds.clear();
497
497
  const rawHtml = marked.parse(body, { async: false }) as string;
498
498
  return sanitizeHtml(rawHtml, {
499
499
  allowedTags: allowlist,
500
500
  allowedAttributes: COMMON_BODY_ATTRIBUTES,
501
- allowedSchemes: ["http", "https", "mailto", "tel"],
501
+ // `allowedStyles` is required because the `span` and `pre` tags
502
+ // accept `style` (for Shiki's CSS variables) — without it
503
+ // sanitize-html would pass any `style` content through, including
504
+ // CSS expressions and `url(javascript:…)` payloads.
505
+ allowedStyles: {
506
+ '*': {
507
+ // All `--shiki-*` custom properties (Shiki's CSS-variable
508
+ // output for code-block tokenization).
509
+ '--shiki-light': [SHIKI_VALUE],
510
+ '--shiki-dark': [SHIKI_VALUE],
511
+ // Hex / rgb() / rgba() / hsl() / hsla() colors only.
512
+ color: [COLOR_VALUE],
513
+ 'background-color': [COLOR_VALUE],
514
+ },
515
+ },
516
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
502
517
  allowedSchemesByTag: {
503
- a: ["http", "https", "mailto", "tel"],
504
- img: ["http", "https", "data"],
518
+ a: ['http', 'https', 'mailto', 'tel'],
519
+ img: ['http', 'https', 'data'],
505
520
  },
506
- allowedSchemesAppliedToAttributes: ["href", "src"],
521
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
507
522
  transformTags: {
508
523
  a: ANCHOR_TRANSFORM,
509
524
  img: IMG_TRANSFORM,
510
525
  input: INPUT_TRANSFORM,
511
526
  },
512
- disallowedTagsMode: "discard",
527
+ disallowedTagsMode: 'discard',
513
528
  });
514
529
  }
515
530
 
516
- /** Per-render counter so heading IDs don't collide within a body. */
531
+ // ── marked configuration (hoisted, runs once at module load) ────────
532
+ //
533
+ // `marked.use({...})` registers a renderer that:
534
+ // 1. Adds a stable `id` to every h2–h6 so the TOC sidebar can
535
+ // deep-link into the rendered body.
536
+ // 2. Wraps GFM tables in `<div class="grove-prose-table-wrap">`
537
+ // so the column-width overflow is contained inside a rounded
538
+ // border instead of breaking the layout.
539
+ // 3. Routes fenced code blocks through Shiki (see below).
540
+ //
541
+ // Heading-ID collision counter is allocated *per render* below and
542
+ // reset before each `marked.parse(...)` call so concurrent renders
543
+ // can never observe each other's counters in the same module instance.
544
+ marked.use({
545
+ gfm: true,
546
+ breaks: false,
547
+ renderer: {
548
+ heading({ tokens, depth }) {
549
+ const inline = this.parser.parseInline(tokens);
550
+ const plain = tokens
551
+ .map((t: { text?: string; raw?: string }) => t.text ?? t.raw ?? '')
552
+ .join('');
553
+ const id =
554
+ depth === 1 ? '' : ` id="${uniqueSlug(headingSlug(plain) || 'section', headingIds)}"`;
555
+ return `<h${depth}${id}>${inline}</h${depth}>\n`;
556
+ },
557
+ table(token: {
558
+ header: Array<{ tokens: unknown[] }>;
559
+ rows: Array<Array<{ tokens: unknown[] }>>;
560
+ }) {
561
+ const head = token.header
562
+ .map((cell) => `<th>${this.parser.parseInline(cell.tokens)}</th>`)
563
+ .join('');
564
+ const body = token.rows
565
+ .map(
566
+ (row) =>
567
+ `<tr>${row
568
+ .map((cell) => `<td>${this.parser.parseInline(cell.tokens)}</td>`)
569
+ .join('')}</tr>`,
570
+ )
571
+ .join('');
572
+ return `<div class="grove-prose-table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>\n`;
573
+ },
574
+ // Fenced code blocks: route through Shiki instead of marked's
575
+ // default `<pre><code class="language-…">…</code></pre>`. The
576
+ // returned HTML already wraps in `<pre>` and `<code>`; we
577
+ // hand it to the sanitizer unchanged (Shiki's output is
578
+ // safe by construction — no script / event handlers).
579
+ code({ text, lang }) {
580
+ return highlightCode(text, lang ?? '') + '\n';
581
+ },
582
+ },
583
+ });
584
+
585
+ /**
586
+ * Heading-ID collision counter. Reset at the start of every
587
+ * `renderMarkdownToSafeHtml` call so heading IDs stay stable per
588
+ * body. Module-level state is acceptable because Node is single-
589
+ * threaded per process; the synchronous `marked.parse(body, {async:false})`
590
+ * returns before any other caller can observe the counter.
591
+ */
517
592
  const headingIds = new Map<string, number>();
518
593
 
519
594
  // ── Shiki syntax highlighter ─────────────────────────────────────────
@@ -544,23 +619,76 @@ const headingIds = new Map<string, number>();
544
619
  // load takes a few hundred ms, so we want to amortise across all
545
620
  // records in a build. The list of supported languages is curated
546
621
  // (not `ALL`) so the build doesn't pull Shiki's full grammar pack.
622
+ //
623
+ // We additionally stash the in-flight promise on `globalThis` under
624
+ // a `Symbol.for` key so Vite HMR — which re-evaluates this module
625
+ // on every dev-server save — does not throw away the cached
626
+ // highlighter and trigger Shiki's "X instances have been created"
627
+ // warning. The shared promise is safe to await from any caller; once
628
+ // it resolves it stays resolved. The previous engine is released
629
+ // exactly once per HMR boundary via `import.meta.hot.dispose`.
630
+ const SHIKI_HIGHLIGHTER = Symbol.for('grove.shiki.highlighter');
631
+ type GlobalWithShiki = typeof globalThis & {
632
+ [SHIKI_HIGHLIGHTER]?: Promise<Awaited<ReturnType<typeof getSingletonHighlighter>>>;
633
+ };
547
634
  const SUPPORTED_LANGS = [
548
- "bash", "sh", "shell", "console",
549
- "python", "py",
550
- "javascript", "js", "jsx", "typescript", "ts", "tsx",
551
- "json", "jsonc", "yaml", "yml", "toml",
552
- "markdown", "md", "mdx",
553
- "html", "css", "scss", "sass",
554
- "sql", "graphql",
555
- "dockerfile", "diff",
556
- "rust", "go", "java", "kotlin", "swift", "ruby", "php",
557
- "c", "cpp", "csharp", "objective-c",
558
- "xml", "ini", "properties",
635
+ 'bash',
636
+ 'sh',
637
+ 'shell',
638
+ 'console',
639
+ 'python',
640
+ 'py',
641
+ 'javascript',
642
+ 'js',
643
+ 'jsx',
644
+ 'typescript',
645
+ 'ts',
646
+ 'tsx',
647
+ 'json',
648
+ 'jsonc',
649
+ 'yaml',
650
+ 'yml',
651
+ 'toml',
652
+ 'markdown',
653
+ 'md',
654
+ 'mdx',
655
+ 'html',
656
+ 'css',
657
+ 'scss',
658
+ 'sass',
659
+ 'sql',
660
+ 'graphql',
661
+ 'dockerfile',
662
+ 'diff',
663
+ 'rust',
664
+ 'go',
665
+ 'java',
666
+ 'kotlin',
667
+ 'swift',
668
+ 'ruby',
669
+ 'php',
670
+ 'c',
671
+ 'cpp',
672
+ 'csharp',
673
+ 'objective-c',
674
+ 'xml',
675
+ 'ini',
676
+ 'properties',
559
677
  ];
560
- const highlighter = await createHighlighter({
561
- themes: ["github-light", "github-dark-default"],
678
+ const globalAny = globalThis as GlobalWithShiki;
679
+ const highlighter = await (globalAny[SHIKI_HIGHLIGHTER] ??= getSingletonHighlighter({
680
+ themes: ['github-light', 'github-dark-default'],
562
681
  langs: SUPPORTED_LANGS,
563
- });
682
+ }));
683
+
684
+ // Release the previous engine's WASM heap on HMR. Without this the
685
+ // old engine is unreachable but not disposed, which is the leak
686
+ // the Shiki warning was originally hinting at.
687
+ if (import.meta.hot) {
688
+ import.meta.hot.dispose(() => {
689
+ globalAny[SHIKI_HIGHLIGHTER]?.then((h) => h.dispose()).catch(() => {});
690
+ });
691
+ }
564
692
 
565
693
  /**
566
694
  * Highlight a fenced code block with Shiki. Returns the Shiki HTML
@@ -569,37 +697,32 @@ const highlighter = await createHighlighter({
569
697
  * scroll behaviour).
570
698
  */
571
699
  function highlightCode(text: string, lang: string): string {
572
- const normalized = lang?.toLowerCase() ?? "";
573
- const safeLang = highlighter.getLoadedLanguages().includes(normalized)
574
- ? normalized
575
- : "text";
700
+ const normalized = lang?.toLowerCase() ?? '';
701
+ const safeLang = highlighter.getLoadedLanguages().includes(normalized) ? normalized : 'text';
576
702
  const html = highlighter.codeToHtml(text, {
577
703
  lang: safeLang,
578
- themes: { light: "github-light", dark: "github-dark-default" },
704
+ themes: { light: 'github-light', dark: 'github-dark-default' },
579
705
  defaultColor: false,
580
706
  });
581
707
  // Strip the outer `<pre>`'s inline `background-color` so it doesn't
582
708
  // fight our package's prose background. The per-token `<span>`
583
709
  // `style="--shiki-light: …; --shiki-dark: …"` declarations must
584
710
  // survive — they're what makes dual-theme work.
585
- return html.replace(
586
- /(<pre[^>]*?)\s+style="[^"]*"/g,
587
- "$1",
588
- );
711
+ return html.replace(/(<pre[^>]*?)\s+style="[^"]*"/g, '$1');
589
712
  }
590
713
 
591
714
  const contentHtmlBySlug = new Map<string, string>();
592
715
  for (const r of fullRecords) {
593
- if (r.kind !== "project") continue;
716
+ if (r.kind !== 'project') continue;
594
717
  const projectRecord = r as ProjectRecord;
595
718
  if (!projectRecord.content) continue;
596
719
  const read = readContentFile(projectRecord.content);
597
720
  if (!read) continue;
598
721
  // Each render needs a fresh headingIds map so the per-body
599
- // collision counter starts at zero. Otherwise the second record
600
- // in the loop would inherit the first record's counters and
601
- // duplicate-heading IDs would collide across records.
602
- headingIds.clear();
722
+ // collision counter starts at zero (cleared inside
723
+ // `renderMarkdownToSafeHtml`); otherwise the second record in the
724
+ // loop would inherit the first record's counters and duplicate-
725
+ // heading IDs would collide across records.
603
726
  try {
604
727
  contentHtmlBySlug.set(r.slug, renderMarkdownToSafeHtml(read.body));
605
728
  } catch {
@@ -627,16 +750,15 @@ export function getContentHtml(slug: string): string | null {
627
750
  */
628
751
  export function getPageContentHtml(page: string): string | null {
629
752
  const candidates = [
630
- resolve(here, "..", "..", "content", "pages", `${page}.md`),
631
- resolve(here, "..", "..", "..", "content", "pages", `${page}.md`),
632
- resolve(process.cwd(), "content", "pages", `${page}.md`),
753
+ resolve(here, '..', '..', 'content', 'pages', `${page}.md`),
754
+ resolve(here, '..', '..', '..', 'content', 'pages', `${page}.md`),
755
+ resolve(process.cwd(), 'content', 'pages', `${page}.md`),
633
756
  ];
634
757
  const path = candidates.find((candidate) => existsSync(candidate));
635
758
  if (!path) return null;
636
759
 
637
760
  try {
638
- const markdown = stripFrontmatter(readFileSync(path, "utf8"));
639
- headingIds.clear();
761
+ const markdown = stripFrontmatter(readFileSync(path, 'utf8'));
640
762
  return renderMarkdownToSafeHtml(markdown, {
641
763
  allowlist: PAGE_BODY_ALLOWLIST,
642
764
  });