@grove-dev/astro 0.4.1 → 0.5.0-next.2
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/package.json +3 -2
- package/src/components/EditorialSummary.astro +57 -0
- package/src/components/IndexRow.astro +2 -2
- package/src/components/ItemCard.astro +2 -1
- package/src/components/LanguageBreakdown.astro +63 -0
- package/src/components/MarkdownBody.astro +37 -0
- package/src/components/RecordHeader.astro +187 -0
- package/src/components/RecordSidebar.astro +339 -0
- package/src/components/SubmissionClient.astro +47 -3
- package/src/components/TableOfContents.astro +115 -0
- package/src/server/collections.ts +77 -1
- package/src/server/directory.ts +311 -65
- package/src/server/models.ts +178 -5
- package/src/styles.css +485 -59
package/src/server/directory.ts
CHANGED
|
@@ -38,7 +38,13 @@ 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 { createHighlighter } from "shiki";
|
|
41
42
|
import { prettySlug } from "../lib/display.js";
|
|
43
|
+
import {
|
|
44
|
+
readContentFile,
|
|
45
|
+
stripFrontmatter,
|
|
46
|
+
uniqueSlug,
|
|
47
|
+
} from "@grove-dev/core";
|
|
42
48
|
import type {
|
|
43
49
|
ProjectRecord,
|
|
44
50
|
ResourceRecord,
|
|
@@ -287,13 +293,299 @@ export const fullProjects: ProjectRecord[] = fullRecords.filter(
|
|
|
287
293
|
// missing. No page module needs to import `node:fs` anymore.
|
|
288
294
|
|
|
289
295
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
296
|
+
|
|
297
|
+
// ── Markdown rendering ────────────────────────────────────────────────
|
|
298
|
+
//
|
|
299
|
+
// Record bodies and consumer-authored pages both go through the
|
|
300
|
+
// same pipeline:
|
|
301
|
+
//
|
|
302
|
+
// body → marked.parse(body) → sanitizeHtml(html, allowlist)
|
|
303
|
+
//
|
|
304
|
+
// The differences are:
|
|
305
|
+
// - record bodies use the wide `RECORD_BODY_ALLOWLIST` (tables,
|
|
306
|
+
// images, task-list inputs, details/summary, kbd/mark/sub/sup,
|
|
307
|
+
// …) so a curated `.md` sidecar can use the full surface.
|
|
308
|
+
// - page bodies use the narrower `PAGE_BODY_ALLOWLIST` so a
|
|
309
|
+
// page-author's `.md` stays in a conservative prose-only space.
|
|
310
|
+
//
|
|
311
|
+
// Both paths run at module load so `getContentHtml(slug)` and
|
|
312
|
+
// `getPageContentHtml(name)` return pre-sanitized HTML with no
|
|
313
|
+
// per-request work.
|
|
314
|
+
|
|
315
|
+
/** Wide allowlist used for `ProjectRecord.content` Markdown bodies. */
|
|
316
|
+
const RECORD_BODY_ALLOWLIST = [
|
|
317
|
+
// Headings — full depth.
|
|
318
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
319
|
+
// Block-level content.
|
|
320
|
+
"p", "br", "hr", "div", "blockquote",
|
|
321
|
+
// Lists.
|
|
322
|
+
"ul", "ol", "li",
|
|
323
|
+
// Definition lists.
|
|
324
|
+
"dl", "dt", "dd",
|
|
325
|
+
// Tables (GFM).
|
|
326
|
+
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
|
|
327
|
+
"caption", "colgroup", "col",
|
|
328
|
+
// Inline formatting.
|
|
329
|
+
"strong", "em", "b", "i", "u", "s", "del", "ins",
|
|
330
|
+
"mark", "small", "sub", "sup", "kbd", "abbr",
|
|
331
|
+
// `<span>` is only here for Shiki's per-token wrappers inside
|
|
332
|
+
// highlighted code blocks. Sanitize-html's default is to strip
|
|
333
|
+
// any tag not in this list, so without `span` here the syntax
|
|
334
|
+
// highlights silently disappear from fenced code.
|
|
335
|
+
"span",
|
|
336
|
+
// Links + images.
|
|
337
|
+
"a", "img",
|
|
338
|
+
// Code.
|
|
339
|
+
"code", "pre",
|
|
340
|
+
// Forms (task-list checkboxes only).
|
|
341
|
+
"input", "label",
|
|
342
|
+
// Collapsibles and semantic blocks.
|
|
343
|
+
"details", "summary",
|
|
344
|
+
"figure", "figcaption",
|
|
345
|
+
"time",
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
/** Narrow allowlist used for `content/pages/<page>.md`. */
|
|
349
|
+
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",
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Attributes the sanitizer keeps per tag. The shared `* → ["id", "class"]`
|
|
359
|
+
* rule preserves the heading anchors the renderer adds and the
|
|
360
|
+
* Shiki syntax-highlighting classes (`shiki`, `language-bash`,
|
|
361
|
+
* …) on `<pre>` and `<code>` blocks. Image `loading/decoding`
|
|
362
|
+
* get forced to lazy/async to keep page weight down; `<input>`
|
|
363
|
+
* becomes a hardened disabled checkbox for GFM task lists.
|
|
364
|
+
*/
|
|
365
|
+
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"],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/** `<a>` tag normalization — open in a new tab, no opener. */
|
|
384
|
+
const ANCHOR_TRANSFORM = sanitizeHtml.simpleTransform(
|
|
385
|
+
"a",
|
|
386
|
+
{ rel: "noopener noreferrer", target: "_blank" },
|
|
387
|
+
true,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
/** Hardens `<img>` to lazy-load + async-decode. */
|
|
391
|
+
const IMG_TRANSFORM = (
|
|
392
|
+
tagName: string,
|
|
393
|
+
attribs: Record<string, string>,
|
|
394
|
+
) => ({
|
|
395
|
+
tagName,
|
|
396
|
+
attribs: {
|
|
397
|
+
...attribs,
|
|
398
|
+
loading: attribs.loading ?? "lazy",
|
|
399
|
+
decoding: attribs.decoding ?? "async",
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
/** Forces `<input type="checkbox">` to be disabled so task lists
|
|
404
|
+
* render read-only at runtime (no on-page state to persist). */
|
|
405
|
+
const INPUT_TRANSFORM = (
|
|
406
|
+
tagName: string,
|
|
407
|
+
attribs: Record<string, string>,
|
|
408
|
+
) => ({
|
|
409
|
+
tagName,
|
|
410
|
+
attribs: {
|
|
411
|
+
...attribs,
|
|
412
|
+
type: "checkbox",
|
|
413
|
+
disabled: "",
|
|
414
|
+
...(attribs.checked !== undefined ? { checked: "" } : {}),
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
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
|
+
/**
|
|
434
|
+
* Render a Markdown string to sanitized HTML. The renderer is
|
|
435
|
+
* configured once (heading IDs + table wrap) and reused for both
|
|
436
|
+
* record and page bodies.
|
|
437
|
+
*
|
|
438
|
+
* Why pass `body` rather than a slug: this is a pure transform; the
|
|
439
|
+
* caller owns where the body came from (file, in-memory cache, …).
|
|
440
|
+
* `getContentHtml(slug)` is the per-record wrapper that reads the
|
|
441
|
+
* file and calls this.
|
|
442
|
+
*/
|
|
443
|
+
export function renderMarkdownToSafeHtml(
|
|
444
|
+
body: string,
|
|
445
|
+
options: { allowlist?: string[] } = {},
|
|
446
|
+
): string {
|
|
447
|
+
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
|
+
|
|
497
|
+
const rawHtml = marked.parse(body, { async: false }) as string;
|
|
498
|
+
return sanitizeHtml(rawHtml, {
|
|
499
|
+
allowedTags: allowlist,
|
|
500
|
+
allowedAttributes: COMMON_BODY_ATTRIBUTES,
|
|
501
|
+
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
502
|
+
allowedSchemesByTag: {
|
|
503
|
+
a: ["http", "https", "mailto", "tel"],
|
|
504
|
+
img: ["http", "https", "data"],
|
|
505
|
+
},
|
|
506
|
+
allowedSchemesAppliedToAttributes: ["href", "src"],
|
|
507
|
+
transformTags: {
|
|
508
|
+
a: ANCHOR_TRANSFORM,
|
|
509
|
+
img: IMG_TRANSFORM,
|
|
510
|
+
input: INPUT_TRANSFORM,
|
|
511
|
+
},
|
|
512
|
+
disallowedTagsMode: "discard",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Per-render counter so heading IDs don't collide within a body. */
|
|
517
|
+
const headingIds = new Map<string, number>();
|
|
518
|
+
|
|
519
|
+
// ── Shiki syntax highlighter ─────────────────────────────────────────
|
|
520
|
+
//
|
|
521
|
+
// Build-time tokenisation for fenced code blocks. Shiki returns a
|
|
522
|
+
// string of HTML — `<pre class="shiki …"><code>…</code></pre>` —
|
|
523
|
+
// and we drop it straight into the marked output where the code
|
|
524
|
+
// block would otherwise be a flat `<pre><code class="language-…">`.
|
|
525
|
+
//
|
|
526
|
+
// Two themes (light + dark) are loaded; the `defaultColor: false`
|
|
527
|
+
// flag asks Shiki to emit CSS variables (`--shiki-light`,
|
|
528
|
+
// `--shiki-dark`) instead of inline `color:` declarations. The
|
|
529
|
+
// selectors in `styles.css` then swap which set of variables wins
|
|
530
|
+
// when the consumer toggles `.dark` on `<html>`. Net effect:
|
|
531
|
+
// the same HTML is themed correctly in both modes without us
|
|
532
|
+
// re-rendering at runtime.
|
|
533
|
+
//
|
|
534
|
+
// We use `github-dark-default` rather than `github-dark` for the
|
|
535
|
+
// dark side: `github-dark` paints comments at `#6A737D`, which
|
|
536
|
+
// fails WCAG AA contrast (3.72:1) against our `--color-ink-950`
|
|
537
|
+
// `#171717` code-block background and trips the Lighthouse
|
|
538
|
+
// accessibility audit on any record that contains a `# …` shell
|
|
539
|
+
// comment. `github-dark-default` shifts comments to `#8B949E`,
|
|
540
|
+
// which clears 4.5:1 against the same background (~6.3:1).
|
|
541
|
+
//
|
|
542
|
+
// `getSingletonHighlighter()` lazily loads the engine on first use
|
|
543
|
+
// and caches it for every subsequent call — Shiki's WASM/grammar
|
|
544
|
+
// load takes a few hundred ms, so we want to amortise across all
|
|
545
|
+
// records in a build. The list of supported languages is curated
|
|
546
|
+
// (not `ALL`) so the build doesn't pull Shiki's full grammar pack.
|
|
547
|
+
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",
|
|
559
|
+
];
|
|
560
|
+
const highlighter = await createHighlighter({
|
|
561
|
+
themes: ["github-light", "github-dark-default"],
|
|
562
|
+
langs: SUPPORTED_LANGS,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Highlight a fenced code block with Shiki. Returns the Shiki HTML
|
|
567
|
+
* wrapped in our `<pre>`-equivalent class so the existing
|
|
568
|
+
* `.grove-prose pre` rules continue to apply (padding, border,
|
|
569
|
+
* scroll behaviour).
|
|
570
|
+
*/
|
|
571
|
+
function highlightCode(text: string, lang: string): string {
|
|
572
|
+
const normalized = lang?.toLowerCase() ?? "";
|
|
573
|
+
const safeLang = highlighter.getLoadedLanguages().includes(normalized)
|
|
574
|
+
? normalized
|
|
575
|
+
: "text";
|
|
576
|
+
const html = highlighter.codeToHtml(text, {
|
|
577
|
+
lang: safeLang,
|
|
578
|
+
themes: { light: "github-light", dark: "github-dark-default" },
|
|
579
|
+
defaultColor: false,
|
|
580
|
+
});
|
|
581
|
+
// Strip the outer `<pre>`'s inline `background-color` so it doesn't
|
|
582
|
+
// fight our package's prose background. The per-token `<span>`
|
|
583
|
+
// `style="--shiki-light: …; --shiki-dark: …"` declarations must
|
|
584
|
+
// survive — they're what makes dual-theme work.
|
|
585
|
+
return html.replace(
|
|
586
|
+
/(<pre[^>]*?)\s+style="[^"]*"/g,
|
|
587
|
+
"$1",
|
|
588
|
+
);
|
|
297
589
|
}
|
|
298
590
|
|
|
299
591
|
const contentHtmlBySlug = new Map<string, string>();
|
|
@@ -301,36 +593,15 @@ for (const r of fullRecords) {
|
|
|
301
593
|
if (r.kind !== "project") continue;
|
|
302
594
|
const projectRecord = r as ProjectRecord;
|
|
303
595
|
if (!projectRecord.content) continue;
|
|
304
|
-
const
|
|
305
|
-
if (!
|
|
596
|
+
const read = readContentFile(projectRecord.content);
|
|
597
|
+
if (!read) continue;
|
|
598
|
+
// 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();
|
|
306
603
|
try {
|
|
307
|
-
|
|
308
|
-
// `async: false` keeps the call synchronous so we can populate
|
|
309
|
-
// the map at module-load time. (marked v18 defaults to
|
|
310
|
-
// Promise-returning; v9-17 also support this flag.)
|
|
311
|
-
const rawHtml = marked.parse(text, { async: false }) as string;
|
|
312
|
-
const safeHtml = sanitizeHtml(rawHtml, {
|
|
313
|
-
allowedTags: [
|
|
314
|
-
"h1", "h2", "h3", "h4",
|
|
315
|
-
"p", "br", "hr",
|
|
316
|
-
"ul", "ol", "li",
|
|
317
|
-
"strong", "em", "b", "i", "u", "s", "del",
|
|
318
|
-
"a", "code", "pre", "blockquote",
|
|
319
|
-
],
|
|
320
|
-
allowedAttributes: {
|
|
321
|
-
a: ["href", "title", "rel", "target"],
|
|
322
|
-
},
|
|
323
|
-
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
324
|
-
allowedSchemesByTag: { a: ["http", "https", "mailto", "tel"] },
|
|
325
|
-
transformTags: {
|
|
326
|
-
a: sanitizeHtml.simpleTransform("a", {
|
|
327
|
-
rel: "noopener noreferrer",
|
|
328
|
-
target: "_blank",
|
|
329
|
-
}, true),
|
|
330
|
-
},
|
|
331
|
-
disallowedTagsMode: "discard",
|
|
332
|
-
});
|
|
333
|
-
contentHtmlBySlug.set(r.slug, safeHtml);
|
|
604
|
+
contentHtmlBySlug.set(r.slug, renderMarkdownToSafeHtml(read.body));
|
|
334
605
|
} catch {
|
|
335
606
|
// Missing / unreadable / parse-failed content: skip the record
|
|
336
607
|
// rather than render broken HTML. The page treats `null` as
|
|
@@ -364,35 +635,10 @@ export function getPageContentHtml(page: string): string | null {
|
|
|
364
635
|
if (!path) return null;
|
|
365
636
|
|
|
366
637
|
try {
|
|
367
|
-
const markdown = readFileSync(path, "utf8")
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const rawHtml = marked.parse(markdown, { async: false }) as string;
|
|
372
|
-
return sanitizeHtml(rawHtml, {
|
|
373
|
-
allowedTags: [
|
|
374
|
-
"h1", "h2", "h3", "h4",
|
|
375
|
-
"p", "br", "hr",
|
|
376
|
-
"ul", "ol", "li",
|
|
377
|
-
"strong", "em", "b", "i", "u", "s", "del",
|
|
378
|
-
"a", "code", "pre", "blockquote", "img",
|
|
379
|
-
],
|
|
380
|
-
allowedAttributes: {
|
|
381
|
-
a: ["href", "title", "rel", "target"],
|
|
382
|
-
img: ["src", "alt", "title", "width", "height", "loading"],
|
|
383
|
-
},
|
|
384
|
-
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
385
|
-
allowedSchemesByTag: {
|
|
386
|
-
a: ["http", "https", "mailto", "tel"],
|
|
387
|
-
img: ["http", "https"],
|
|
388
|
-
},
|
|
389
|
-
transformTags: {
|
|
390
|
-
a: sanitizeHtml.simpleTransform("a", {
|
|
391
|
-
rel: "noopener noreferrer",
|
|
392
|
-
target: "_blank",
|
|
393
|
-
}, true),
|
|
394
|
-
},
|
|
395
|
-
disallowedTagsMode: "discard",
|
|
638
|
+
const markdown = stripFrontmatter(readFileSync(path, "utf8"));
|
|
639
|
+
headingIds.clear();
|
|
640
|
+
return renderMarkdownToSafeHtml(markdown, {
|
|
641
|
+
allowlist: PAGE_BODY_ALLOWLIST,
|
|
396
642
|
});
|
|
397
643
|
} catch {
|
|
398
644
|
return null;
|
package/src/server/models.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
IndexFilters,
|
|
11
|
+
IndexRecord,
|
|
12
|
+
ProjectRecord,
|
|
13
|
+
ReadingMetrics,
|
|
14
|
+
TocEntry,
|
|
15
|
+
} from "@grove-dev/core";
|
|
10
16
|
import {
|
|
11
17
|
activeFilterChips,
|
|
12
18
|
applySort,
|
|
13
19
|
buildFacets,
|
|
14
20
|
effectivePage,
|
|
15
21
|
effectiveSort,
|
|
22
|
+
extractToc,
|
|
16
23
|
filterRecords,
|
|
17
24
|
filtersFromSearchParams,
|
|
18
25
|
formatRelative,
|
|
@@ -20,6 +27,8 @@ import {
|
|
|
20
27
|
getOwnerAndRepoFromRepoUrl,
|
|
21
28
|
getOwnerAvatarUrl,
|
|
22
29
|
projectStackIds,
|
|
30
|
+
readingMetrics,
|
|
31
|
+
readContentFile,
|
|
23
32
|
statusDisplay,
|
|
24
33
|
totalPages,
|
|
25
34
|
} from "@grove-dev/core";
|
|
@@ -177,7 +186,10 @@ function configuredFacets(site?: DirectorySiteConfig) {
|
|
|
177
186
|
|
|
178
187
|
export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: DirectorySiteConfig) {
|
|
179
188
|
const filters = filtersFromSearchParams(searchParams);
|
|
180
|
-
const rawFacets = buildFacets(items
|
|
189
|
+
const rawFacets = buildFacets(items, {
|
|
190
|
+
curatedTagIds: site.taxonomy?.topics?.map((t) => t.id),
|
|
191
|
+
filters, // Intersection counts: each facet reflects all OTHER filters.
|
|
192
|
+
});
|
|
181
193
|
const enabled = configuredFacets(site);
|
|
182
194
|
const facets = {
|
|
183
195
|
stacks: enabled.has("stacks") ? rawFacets.stacks.map((option) => ({ ...option, label: taxonomyLabel("stacks", option.value) })) : [],
|
|
@@ -206,7 +218,13 @@ export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: Dir
|
|
|
206
218
|
|
|
207
219
|
export function getContributorsPageModel(site: DirectorySiteConfig) {
|
|
208
220
|
const contributors = loadDirectoryContributors();
|
|
209
|
-
|
|
221
|
+
// Render-time guard: even if a stale `data/generated/contributors.json`
|
|
222
|
+
// was produced before the sync-time filter was added, never surface
|
|
223
|
+
// bot accounts (login ends with `[bot]`) on the public contributors
|
|
224
|
+
// page. The ContributorsGrid uses the same total for its heading, so
|
|
225
|
+
// the headline count always agrees with the rendered grid.
|
|
226
|
+
const human = contributors.filter((c) => !c.username.endsWith("[bot]"));
|
|
227
|
+
const sorted = [...human].sort((a, b) =>
|
|
210
228
|
(b.contributions ?? 0) - (a.contributions ?? 0) || a.username.localeCompare(b.username)
|
|
211
229
|
);
|
|
212
230
|
const repo = site.repoUrl?.replace(/\/$/, "");
|
|
@@ -295,6 +313,8 @@ export function getRecordDetailModel(
|
|
|
295
313
|
const language = github?.language ?? null;
|
|
296
314
|
const licenseSpdx = github?.license?.spdx_id ?? null;
|
|
297
315
|
const pushedAt = github?.pushed_at ?? null;
|
|
316
|
+
const isArchived = !!github?.archived;
|
|
317
|
+
const isDisabled = !!github?.disabled;
|
|
298
318
|
const ownerRepo = repoUrl ? getOwnerAndRepoFromRepoUrl(repoUrl) : null;
|
|
299
319
|
const avatarSrc = proj?.logoUrl ?? (ownerRepo ? getOwnerAvatarUrl(ownerRepo.owner, 80) : null);
|
|
300
320
|
const rawScores = proj?.scores;
|
|
@@ -322,6 +342,9 @@ export function getRecordDetailModel(
|
|
|
322
342
|
const languages = extras.github?.languages
|
|
323
343
|
? Object.entries(extras.github.languages).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
324
344
|
: [];
|
|
345
|
+
const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
|
|
346
|
+
const tags = record.tags ?? [];
|
|
347
|
+
const tocBody = readContentFile(typeof record.content === "string" ? record.content : "");
|
|
325
348
|
|
|
326
349
|
let jsonLd: Record<string, unknown>;
|
|
327
350
|
if (isProject && proj) {
|
|
@@ -416,18 +439,168 @@ export function getRecordDetailModel(
|
|
|
416
439
|
distribution: proj?.distribution?.channels ?? [],
|
|
417
440
|
scores,
|
|
418
441
|
curationLabels: record.curation?.labels ?? [],
|
|
419
|
-
tags
|
|
420
|
-
healthLabel
|
|
442
|
+
tags,
|
|
443
|
+
healthLabel,
|
|
421
444
|
contentHtml: isProject ? getContentHtml(recordSlug) : null,
|
|
445
|
+
// Curated summary (Open Apps-written) takes priority over the
|
|
446
|
+
// raw `description` (typically copied from GitHub). Fall back to
|
|
447
|
+
// `description` when the curator has not written a summary yet.
|
|
448
|
+
summary: (record.summary && record.summary.trim()) || record.description || "",
|
|
449
|
+
sourceDescription: record.sourceDescription ?? record.description ?? "",
|
|
450
|
+
// Collection membership is populated by the consumer detail page
|
|
451
|
+
// after calling `findCollectionsFor`. Defaulting to [] keeps the
|
|
452
|
+
// model safe when the consumer doesn't wire it.
|
|
453
|
+
collectionMembership: [],
|
|
454
|
+
// Curated screenshots array. Defaulting to [] keeps the renderer
|
|
455
|
+
// safe; RecordHeader shows a gallery strip when non-empty.
|
|
456
|
+
screenshots: proj?.screenshots ?? [],
|
|
422
457
|
monthlyCommits,
|
|
423
458
|
maxMonthlyCommits: Math.max(1, ...monthlyCommits.map((item) => item.commits)),
|
|
424
459
|
contributionSignals,
|
|
425
460
|
languages,
|
|
426
461
|
totalLanguageBytes: Math.max(1, languages.reduce((sum, [, bytes]) => sum + bytes, 0)),
|
|
462
|
+
// ── Sidebar-shape fields ────────────────────────────────
|
|
463
|
+
// Read once at model-build time so pages don't repeat the
|
|
464
|
+
// "is there data for this card?" logic.
|
|
465
|
+
activityBadge: computeActivityBadge({
|
|
466
|
+
pushedAt,
|
|
467
|
+
monthlyCommits,
|
|
468
|
+
isArchived,
|
|
469
|
+
isDisabled,
|
|
470
|
+
}),
|
|
471
|
+
sidebar: computeSidebarVisibility({
|
|
472
|
+
language,
|
|
473
|
+
licenseSpdx,
|
|
474
|
+
repo: github,
|
|
475
|
+
pushedAt,
|
|
476
|
+
isArchived,
|
|
477
|
+
healthLabel,
|
|
478
|
+
stacks,
|
|
479
|
+
platforms,
|
|
480
|
+
tags,
|
|
481
|
+
category: record.category,
|
|
482
|
+
contributionSignals,
|
|
483
|
+
reviewedAt: record.curation?.reviewedAt,
|
|
484
|
+
}),
|
|
485
|
+
// ── Body-derived fields ──────────────────────────────────
|
|
486
|
+
// Both depend on the sidecar Markdown; the body is re-read
|
|
487
|
+
// here even though it's also read for `contentHtml` upstream
|
|
488
|
+
// because the TOC + reading-time want the *body* (frontmatter
|
|
489
|
+
// stripped), not the HTML. Re-reading is cheap and keeps the
|
|
490
|
+
// pipeline self-documenting.
|
|
491
|
+
toc: tocBody ? extractToc(tocBody.body, { maxDepth: 2 }) : [],
|
|
492
|
+
readingMetrics: tocBody ? readingMetrics(tocBody.body) : { wordCount: 0, minutes: 1 },
|
|
427
493
|
jsonLd,
|
|
428
494
|
};
|
|
429
495
|
}
|
|
430
496
|
|
|
497
|
+
// ── Sidebar predicates ────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Tone + label for the activity badge that appears in the header.
|
|
501
|
+
*
|
|
502
|
+
* Rules (intentionally explicit so a reader can predict the badge
|
|
503
|
+
* without consulting a weighting formula):
|
|
504
|
+
*
|
|
505
|
+
* - Archived or disabled repo → "Archived"
|
|
506
|
+
* - Recent monthly-commit total ≥ 50 → "Very active"
|
|
507
|
+
* - ≥ 10 recent commits, or last push < 30d ago → "Active"
|
|
508
|
+
* - ≥ 1 commit, or last push < 180d ago → "Maintained"
|
|
509
|
+
* - last push 180d–365d → "Low activity"
|
|
510
|
+
* - last push > 365d → "Stale"
|
|
511
|
+
* - no push data and no commit data → null
|
|
512
|
+
*
|
|
513
|
+
* Returns `null` when neither GitHub push nor monthly commits are
|
|
514
|
+
* present (e.g. an un-synced record), so the badge simply doesn't
|
|
515
|
+
* render.
|
|
516
|
+
*/
|
|
517
|
+
export function computeActivityBadge(input: {
|
|
518
|
+
pushedAt: string | null;
|
|
519
|
+
monthlyCommits: Array<{ month: string; commits: number }>;
|
|
520
|
+
isArchived: boolean;
|
|
521
|
+
isDisabled: boolean;
|
|
522
|
+
}): { label: string; tone: "fresh" | "ok" | "low" | "dead" } | null {
|
|
523
|
+
if (input.isArchived || input.isDisabled) {
|
|
524
|
+
return { label: "Archived", tone: "dead" };
|
|
525
|
+
}
|
|
526
|
+
const recentCommits = (input.monthlyCommits ?? [])
|
|
527
|
+
.slice(-3)
|
|
528
|
+
.reduce((sum, c) => sum + (c.commits ?? 0), 0);
|
|
529
|
+
if (recentCommits >= 50) return { label: "Very active", tone: "fresh" };
|
|
530
|
+
if (recentCommits >= 10) return { label: "Active", tone: "ok" };
|
|
531
|
+
if (recentCommits > 0) return { label: "Maintained", tone: "ok" };
|
|
532
|
+
if (input.pushedAt) {
|
|
533
|
+
const days =
|
|
534
|
+
(Date.now() - new Date(input.pushedAt).getTime()) / 86_400_000;
|
|
535
|
+
if (days < 30) return { label: "Active", tone: "ok" };
|
|
536
|
+
if (days < 180) return { label: "Maintained", tone: "ok" };
|
|
537
|
+
if (days < 365) return { label: "Low activity", tone: "low" };
|
|
538
|
+
return { label: "Stale", tone: "low" };
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Visibility booleans for each sidebar card. Cards with no data
|
|
545
|
+
* suppress themselves entirely so the sidebar never shows "—" or
|
|
546
|
+
* "0" placeholders that confuse "we haven't fetched yet" with
|
|
547
|
+
* "the value is genuinely zero".
|
|
548
|
+
*/
|
|
549
|
+
export function computeSidebarVisibility(input: {
|
|
550
|
+
language: string | null;
|
|
551
|
+
licenseSpdx: string | null;
|
|
552
|
+
repo: Record<string, unknown> | null | undefined;
|
|
553
|
+
pushedAt: string | null;
|
|
554
|
+
isArchived: boolean;
|
|
555
|
+
healthLabel: string | null;
|
|
556
|
+
stacks: string[];
|
|
557
|
+
platforms: string[];
|
|
558
|
+
tags: string[];
|
|
559
|
+
category: string | undefined;
|
|
560
|
+
contributionSignals: Array<{ key: string; label: string; ok: boolean }>;
|
|
561
|
+
reviewedAt: string | undefined;
|
|
562
|
+
}): {
|
|
563
|
+
showActivity: boolean;
|
|
564
|
+
showFreshness: boolean;
|
|
565
|
+
showEcosystem: boolean;
|
|
566
|
+
showSource: boolean;
|
|
567
|
+
} {
|
|
568
|
+
const repoFields = input.repo ?? {};
|
|
569
|
+
const hasRepo =
|
|
570
|
+
typeof input.repo === "object" &&
|
|
571
|
+
input.repo !== null &&
|
|
572
|
+
Object.keys(repoFields).length > 0;
|
|
573
|
+
return {
|
|
574
|
+
showActivity:
|
|
575
|
+
hasRepo ||
|
|
576
|
+
!!input.language ||
|
|
577
|
+
!!input.licenseSpdx,
|
|
578
|
+
showFreshness:
|
|
579
|
+
hasRepo ||
|
|
580
|
+
!!input.pushedAt ||
|
|
581
|
+
!!input.healthLabel ||
|
|
582
|
+
input.isArchived,
|
|
583
|
+
showEcosystem:
|
|
584
|
+
input.stacks.length > 0 ||
|
|
585
|
+
input.platforms.length > 0 ||
|
|
586
|
+
input.tags.length > 0 ||
|
|
587
|
+
!!input.category ||
|
|
588
|
+
input.contributionSignals.length > 0,
|
|
589
|
+
showSource: !!input.reviewedAt,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* `getStaticPaths()` body for the consumer's detail page. Centralized
|
|
595
|
+
* here so the CLI's scaffold and the example app share one definition.
|
|
596
|
+
*/
|
|
597
|
+
export function recordDetailPaths(site: DirectorySiteConfig) {
|
|
598
|
+
const dirSlug = site.blueprintConfig?.routeSlug ?? "items";
|
|
599
|
+
return fullItems.map((record) => ({
|
|
600
|
+
params: { slug: dirSlug, recordSlug: record.slug },
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
|
|
431
604
|
export type DirectoryIndexModel = ReturnType<typeof getDirectoryIndexModel>;
|
|
432
605
|
export type DirectoryHomeModel = ReturnType<typeof getHomePageModel>;
|
|
433
606
|
export type SubmissionPageModel = ReturnType<typeof getSubmissionPageModel>;
|