@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.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -2
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -2
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +45 -0
- package/dist/ui/button.d.ts.map +1 -0
- package/dist/ui/button.js +82 -0
- package/dist/ui/button.js.map +1 -0
- package/package.json +8 -5
- package/src/components/CardGrid.astro +41 -0
- package/src/components/CardIcon.astro +67 -0
- package/src/components/CategoryGrid.astro +20 -5
- package/src/components/CollectionCard.astro +56 -0
- package/src/components/CollectionIndex.astro +21 -26
- package/src/components/CollectionPage.astro +36 -13
- package/src/components/CollectionRow.astro +62 -192
- package/src/components/CollectionTeaser.astro +4 -0
- package/src/components/ContributorsGrid.astro +4 -1
- package/src/components/DirectoryIndexClient.astro +127 -30
- package/src/components/EditorialSummary.astro +6 -5
- package/src/components/FilterGroupMenu.astro +24 -10
- package/src/components/FilterOptions.astro +6 -1
- package/src/components/FinalCta.astro +5 -3
- package/src/components/Hero.astro +85 -139
- package/src/components/IndexRow.astro +36 -69
- package/src/components/LanguageBreakdown.astro +5 -1
- package/src/components/OriginalCollection.astro +3 -2
- package/src/components/Pagination.astro +7 -20
- package/src/components/ProjectCard.astro +345 -0
- package/src/components/RecordHeader.astro +111 -82
- package/src/components/RecordSection.astro +14 -10
- package/src/components/RecordSidebar.astro +68 -20
- package/src/components/RefinePanel.astro +41 -62
- package/src/components/SmartLensTabs.astro +3 -7
- package/src/components/StackGrid.astro +26 -6
- package/src/components/SubmissionClient.astro +140 -63
- package/src/components/TableOfContents.astro +31 -5
- package/src/components/WhyThisExists.astro +1 -1
- package/src/index.ts +2 -2
- package/src/layouts/BaseLayout.astro +79 -9
- package/src/layouts/Header.astro +5 -4
- package/src/layouts/SectionHeader.astro +3 -2
- package/src/layouts/Seo.astro +28 -17
- package/src/layouts/ThemeToggle.astro +23 -6
- package/src/lib/index.ts +0 -2
- package/src/server/collections.ts +11 -0
- package/src/server/contrast.test.ts +82 -0
- package/src/server/contrast.ts +117 -0
- package/src/server/directory.test.ts +177 -0
- package/src/server/directory.ts +328 -206
- package/src/server/github-repo.test.ts +88 -0
- package/src/server/github-repo.ts +104 -0
- package/src/server/index.ts +4 -3
- package/src/server/models-home.test.ts +101 -0
- package/src/server/models.test.ts +135 -0
- package/src/server/models.ts +170 -42
- package/src/styles.css +128 -16
- package/dist/lib/scores.d.ts +0 -2
- package/dist/lib/scores.d.ts.map +0 -1
- package/dist/lib/scores.js +0 -2
- package/dist/lib/scores.js.map +0 -1
- package/src/components/CurationGrid.astro +0 -41
- package/src/components/DecisionRow.astro +0 -89
- package/src/components/ExploreByCategory.astro +0 -67
- package/src/components/ExploreByStack.astro +0 -78
- package/src/components/GroveDocumentHead.astro +0 -28
- package/src/components/ItemCard.astro +0 -324
- package/src/components/MinimalAbout.astro +0 -82
- package/src/components/ScoreBars.astro +0 -102
- package/src/lib/scores.ts +0 -1
package/src/server/directory.ts
CHANGED
|
@@ -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
|
|
34
|
-
import indexPayload from
|
|
35
|
-
import siteConfigPayload from
|
|
36
|
-
import { existsSync, readFileSync } from
|
|
37
|
-
import { fileURLToPath } from
|
|
38
|
-
import { dirname, resolve } from
|
|
39
|
-
import { marked } from
|
|
40
|
-
import sanitizeHtml from
|
|
41
|
-
import {
|
|
42
|
-
import { prettySlug } from
|
|
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
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ??
|
|
193
|
-
|
|
194
|
-
|
|
|
195
|
-
|
|
|
196
|
-
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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():
|
|
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
|
|
231
|
+
case 'resource':
|
|
247
232
|
return resources;
|
|
248
|
-
case
|
|
233
|
+
case 'entity':
|
|
249
234
|
return entities;
|
|
250
|
-
case
|
|
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 ===
|
|
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
|
-
|
|
303
|
+
'h1',
|
|
304
|
+
'h2',
|
|
305
|
+
'h3',
|
|
306
|
+
'h4',
|
|
307
|
+
'h5',
|
|
308
|
+
'h6',
|
|
319
309
|
// Block-level content.
|
|
320
|
-
|
|
310
|
+
'p',
|
|
311
|
+
'br',
|
|
312
|
+
'hr',
|
|
313
|
+
'div',
|
|
314
|
+
'blockquote',
|
|
321
315
|
// Lists.
|
|
322
|
-
|
|
316
|
+
'ul',
|
|
317
|
+
'ol',
|
|
318
|
+
'li',
|
|
323
319
|
// Definition lists.
|
|
324
|
-
|
|
320
|
+
'dl',
|
|
321
|
+
'dt',
|
|
322
|
+
'dd',
|
|
325
323
|
// Tables (GFM).
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
353
|
+
'span',
|
|
336
354
|
// Links + images.
|
|
337
|
-
|
|
355
|
+
'a',
|
|
356
|
+
'img',
|
|
338
357
|
// Code.
|
|
339
|
-
|
|
358
|
+
'code',
|
|
359
|
+
'pre',
|
|
340
360
|
// Forms (task-list checkboxes only).
|
|
341
|
-
|
|
361
|
+
'input',
|
|
362
|
+
'label',
|
|
342
363
|
// Collapsibles and semantic blocks.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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: [
|
|
367
|
-
img: [
|
|
368
|
-
th: [
|
|
369
|
-
td: [
|
|
370
|
-
col: [
|
|
371
|
-
input: [
|
|
372
|
-
label: [
|
|
373
|
-
abbr: [
|
|
374
|
-
time: [
|
|
375
|
-
details: [
|
|
376
|
-
div: [
|
|
377
|
-
span: [
|
|
378
|
-
pre: [
|
|
379
|
-
code: [
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
{ rel:
|
|
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 ??
|
|
399
|
-
decoding: attribs.decoding ??
|
|
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:
|
|
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
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
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
|
-
|
|
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: [
|
|
504
|
-
img: [
|
|
518
|
+
a: ['http', 'https', 'mailto', 'tel'],
|
|
519
|
+
img: ['http', 'https', 'data'],
|
|
505
520
|
},
|
|
506
|
-
allowedSchemesAppliedToAttributes: [
|
|
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:
|
|
527
|
+
disallowedTagsMode: 'discard',
|
|
513
528
|
});
|
|
514
529
|
}
|
|
515
530
|
|
|
516
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
561
|
-
|
|
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:
|
|
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 !==
|
|
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
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
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,
|
|
631
|
-
resolve(here,
|
|
632
|
-
resolve(process.cwd(),
|
|
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,
|
|
639
|
-
headingIds.clear();
|
|
761
|
+
const markdown = stripFrontmatter(readFileSync(path, 'utf8'));
|
|
640
762
|
return renderMarkdownToSafeHtml(markdown, {
|
|
641
763
|
allowlist: PAGE_BODY_ALLOWLIST,
|
|
642
764
|
});
|