Skip to content

Commit 513afa8

Browse files
committed
feat: show task maturity and better card intros
1 parent 7f49d3d commit 513afa8

11 files changed

Lines changed: 352 additions & 97 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ It:
5959
- optional metadata `task.yaml` / `task.yml` / `task.json` (repo root)
6060
4. Produces conservative inferred tags when metadata is missing (primarily from repo name).
6161

62+
Additional parsing:
63+
64+
- If the README includes the standard metadata table, the indexer will use `Short Description`, `PsyFlow Version`, `Language`, and `Voice Name` when present.
65+
- If the README includes a maturity badge like `![Maturity: smoke_tested]`, the maturity value is extracted and shown on cards.
66+
6267
Rate limits:
6368

6469
- Without token, GitHub API is rate-limited and the script may fail.
@@ -83,6 +88,7 @@ keywords:
8388
- stroop
8489
- interference
8590

91+
maturity: "smoke_tested" # optional (e.g. smoke_tested, piloted)
8692
psyflow_version: "^0.2.0" # optional
8793
has_voiceover: false # optional
8894
```

scripts/build-index.mjs

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,76 @@ function stripMarkdownInline(s) {
171171
.trim();
172172
}
173173

174+
function truncate(s, max = 140) {
175+
const t = String(s ?? "").trim();
176+
if (!t) return "";
177+
return t.length > max ? `${t.slice(0, max - 1)}?` : t;
178+
}
179+
180+
function readmeTableValue(markdown, fieldName) {
181+
const wanted = String(fieldName ?? "").trim().toLowerCase();
182+
if (!wanted) return "";
183+
184+
const lines = String(markdown ?? "").split(/\r?\n/);
185+
for (const line of lines) {
186+
const t = String(line ?? "").trim();
187+
if (!t.startsWith("|")) continue;
188+
189+
// Separator rows like: |-----|-----|
190+
if (/^\|\s*:?-+:?\s*\|/.test(t)) continue;
191+
192+
let cols = t.split("|").map((c) => c.trim());
193+
while (cols.length && cols[0] === "") cols = cols.slice(1);
194+
while (cols.length && cols[cols.length - 1] === "") cols = cols.slice(0, -1);
195+
if (cols.length < 2) continue;
196+
197+
const key = String(cols[0] ?? "").trim().toLowerCase();
198+
if (!key || key !== wanted) continue;
199+
200+
const value = stripMarkdownInline(cols.slice(1).join(" | "));
201+
if (!value) continue;
202+
return value;
203+
}
204+
205+
return "";
206+
}
207+
208+
function normalizeLanguageLabel(v) {
209+
const s = String(v ?? "").trim();
210+
if (!s) return "";
211+
const lower = s.toLowerCase();
212+
if (lower === "en" || /\benglish\b/i.test(lower)) return "English";
213+
if (lower === "zh" || /^zh\b/.test(lower) || /\bchinese\b/i.test(lower) || /\u4e2d\u6587/.test(s)) return "Chinese";
214+
return s;
215+
}
216+
217+
function normalizeMaturity(v) {
218+
const s = String(v ?? "").trim();
219+
if (!s) return "";
220+
return s.toLowerCase().replace(/\s+/g, "_");
221+
}
222+
223+
function extractMaturityFromReadme(markdown) {
224+
const fromTable = readmeTableValue(markdown, "Maturity");
225+
if (fromTable) return fromTable;
226+
227+
const m1 = /!\[\s*Maturity\s*:\s*([^\]]+?)\s*\]/i.exec(String(markdown ?? ""));
228+
if (m1?.[1]) return m1[1].trim();
229+
230+
// shields.io style: .../badge/Maturity-smoke_tested-...
231+
const m2 = /badge\/Maturity-([^\-\s\)]+)-/i.exec(String(markdown ?? ""));
232+
if (m2?.[1]) {
233+
try {
234+
return decodeURIComponent(m2[1]).trim();
235+
} catch {
236+
return String(m2[1]).trim();
237+
}
238+
}
239+
240+
return "";
241+
}
242+
243+
174244
function firstParagraphDescription(markdown) {
175245
const text = String(markdown ?? "").replace(/<!--([\s\S]*?)-->/g, "");
176246
const lines = text.split(/\r?\n/).map((l) => l.trim());
@@ -254,7 +324,9 @@ function inferTagsFromRepo(repoName, markdown) {
254324
const language = [];
255325
// Only infer language when explicitly stated.
256326
if (/\blanguage\s*:\s*en\b/i.test(hay) || /\benglish\b/i.test(hay))
257-
language.push("en");
327+
language.push("English");
328+
if (/\blanguage\s*:\s*zh\b/i.test(hay) || /\bchinese\b/i.test(hay) || /\u4e2d\u6587/.test(hay))
329+
language.push("Chinese");
258330

259331
return {
260332
paradigm: uniq(paradigm),
@@ -354,9 +426,15 @@ async function buildIndex() {
354426
const readmeUrl = `${GH_API}/repos/${encodeURIComponent(ORG)}/${encodeURIComponent(repo)}/readme`;
355427
const readme = await ghGetRaw(readmeUrl, { allow404: true });
356428

429+
const shortFromReadmeTable = readmeTableValue(readme, "Short Description");
357430
const shortFromReadme = firstParagraphDescription(readme);
358431
const shortFromRepo = String(r.description ?? "").trim();
359432

433+
const maturity = normalizeMaturity(
434+
(typeof meta?.maturity === "string" ? meta.maturity : "") ||
435+
extractMaturityFromReadme(readme)
436+
) || null;
437+
360438
const tagsFromMeta = normalizeTags(meta?.tags);
361439
const tagsInferred = inferTagsFromRepo(repo, readme);
362440

@@ -367,6 +445,14 @@ async function buildIndex() {
367445
language: uniq([...(tagsFromMeta.language ?? []), ...(tagsInferred.language ?? [])])
368446
};
369447

448+
// Prefer explicit README table language if present.
449+
const languageFromReadme = normalizeLanguageLabel(readmeTableValue(readme, "Language"));
450+
if (languageFromReadme) {
451+
tags.language = uniq([...(tags.language ?? []), languageFromReadme]);
452+
}
453+
454+
tags.language = uniq((tags.language ?? []).map(normalizeLanguageLabel).filter(Boolean));
455+
370456
const keywords = uniq([
371457
...toArray(meta?.keywords).map(String),
372458
...tags.paradigm,
@@ -377,15 +463,31 @@ async function buildIndex() {
377463
]);
378464

379465
let short_description = String(meta?.short_description ?? "").trim();
380-
if (!short_description) short_description = shortFromRepo;
466+
if (!short_description) short_description = shortFromReadmeTable;
381467
if (!short_description) short_description = shortFromReadme;
468+
if (!short_description) short_description = shortFromRepo;
382469
if (!short_description) {
383470
const p = tags.paradigm?.[0];
384471
short_description = p
385472
? `${p} task template (PsyFlow/TAPS).`
386473
: `${repo} task template (PsyFlow/TAPS).`;
387474
}
388475

476+
short_description = truncate(short_description);
477+
478+
let psyflow_version = meta?.psyflow_version ?? null;
479+
if (!psyflow_version) {
480+
const v = readmeTableValue(readme, "PsyFlow Version");
481+
psyflow_version = v || null;
482+
}
483+
484+
let has_voiceover =
485+
typeof meta?.has_voiceover === "boolean" ? meta.has_voiceover : null;
486+
if (has_voiceover === null) {
487+
const voiceName = readmeTableValue(readme, "Voice Name");
488+
if (voiceName) has_voiceover = true;
489+
}
490+
389491
const run_anchor = readme ? detectRunAnchor(readme) : "#run";
390492

391493
const item = {
@@ -394,11 +496,11 @@ async function buildIndex() {
394496
html_url: r.html_url,
395497
default_branch: r.default_branch,
396498
short_description,
499+
maturity,
397500
tags,
398501
keywords,
399-
psyflow_version: meta?.psyflow_version ?? null,
400-
has_voiceover:
401-
typeof meta?.has_voiceover === "boolean" ? meta.has_voiceover : null,
502+
psyflow_version,
503+
has_voiceover,
402504
last_updated: r.pushed_at || r.updated_at,
403505
structure,
404506
readme_run_anchor: run_anchor
@@ -416,7 +518,7 @@ async function buildIndex() {
416518
tasks.sort((a, b) => (a.last_updated < b.last_updated ? 1 : -1));
417519

418520
const index = {
419-
schema_version: 1,
521+
schema_version: 2,
420522
generated_at: new Date().toISOString(),
421523
org: ORG,
422524
tasks

src/app/_components/gallery-client.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useMemo, useState } from "react";
4-
import type { TaskIndexItem, TaskTagFacet } from "@/lib/task-index";
4+
import type { TaskFacet, TaskIndexItem, TaskTagFacet } from "@/lib/task-index";
55
import {
66
emptySelectedFacets,
77
facetValues,
@@ -10,6 +10,7 @@ import {
1010
} from "@/lib/task-filter";
1111
import { TagChip } from "@/components/tag-chip";
1212
import { TaskCard } from "@/components/task-card";
13+
import { formatMaturityLabel } from "@/components/maturity-badge";
1314

1415
function FacetSection({
1516
title,
@@ -19,10 +20,10 @@ function FacetSection({
1920
onToggle
2021
}: {
2122
title: string;
22-
facet: TaskTagFacet;
23+
facet: TaskFacet;
2324
values: string[];
2425
selected: SelectedFacets;
25-
onToggle: (facet: TaskTagFacet, value: string) => void;
26+
onToggle: (facet: TaskFacet, value: string) => void;
2627
}) {
2728
if (values.length === 0) return null;
2829

@@ -42,7 +43,7 @@ function FacetSection({
4243
{values.map((v) => (
4344
<TagChip
4445
key={`${facet}:${v}`}
45-
label={v}
46+
label={facet === "maturity" ? formatMaturityLabel(v) : v}
4647
selected={selected[facet].has(v)}
4748
onClick={() => onToggle(facet, v)}
4849
/>
@@ -56,6 +57,7 @@ export function GalleryClient({ tasks }: { tasks: TaskIndexItem[] }) {
5657
const [query, setQuery] = useState<string>("");
5758
const [selected, setSelected] = useState<SelectedFacets>(() => emptySelectedFacets());
5859

60+
const allMaturities = useMemo(() => facetValues(tasks, "maturity"), [tasks]);
5961
const allParadigms = useMemo(() => facetValues(tasks, "paradigm"), [tasks]);
6062
const allResponses = useMemo(() => facetValues(tasks, "response"), [tasks]);
6163
const allModalities = useMemo(() => facetValues(tasks, "modality"), [tasks]);
@@ -65,14 +67,16 @@ export function GalleryClient({ tasks }: { tasks: TaskIndexItem[] }) {
6567

6668
const anyFilters =
6769
query.trim().length > 0 ||
70+
selected.maturity.size > 0 ||
6871
selected.paradigm.size > 0 ||
6972
selected.response.size > 0 ||
7073
selected.modality.size > 0 ||
7174
selected.language.size > 0;
7275

73-
function toggleFacet(facet: TaskTagFacet, value: string) {
76+
function toggleFacet(facet: TaskFacet, value: string) {
7477
setSelected((prev) => {
7578
const next: SelectedFacets = {
79+
maturity: new Set(prev.maturity),
7680
paradigm: new Set(prev.paradigm),
7781
response: new Set(prev.response),
7882
modality: new Set(prev.modality),
@@ -121,6 +125,13 @@ export function GalleryClient({ tasks }: { tasks: TaskIndexItem[] }) {
121125
</div>
122126
</section>
123127

128+
<FacetSection
129+
title="Maturity"
130+
facet="maturity"
131+
values={allMaturities}
132+
selected={selected}
133+
onToggle={toggleFacet}
134+
/>
124135
<FacetSection
125136
title="Paradigm"
126137
facet="paradigm"
@@ -177,7 +188,7 @@ export function GalleryClient({ tasks }: { tasks: TaskIndexItem[] }) {
177188
<TaskCard
178189
key={t.repo}
179190
task={t}
180-
onTagClick={(facet, value) => toggleFacet(facet, value)}
191+
onTagClick={(facet: TaskTagFacet, value) => toggleFacet(facet, value)}
181192
/>
182193
))}
183194
</div>

src/app/about/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ keywords:
9090
- stroop
9191
- interference
9292
93+
maturity: "smoke_tested" # optional (e.g. smoke_tested, piloted)
9394
psyflow_version: "^0.2.0" # optional
9495
has_voiceover: false # optional`}</code>
9596
</pre>

src/app/tasks/[repo]/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Metadata } from "next";
55
import { notFound } from "next/navigation";
66

77
import { CopyButton } from "@/components/copy-button";
8+
import { MaturityBadge } from "@/components/maturity-badge";
89
import { Markdown } from "@/components/markdown";
910
import { formatShortDate } from "@/lib/format";
1011
import { findTaskByRepo, getTasks, taskLinks } from "@/lib/task-index";
@@ -132,6 +133,13 @@ export default function TaskPage({ params }: { params: { repo: string } }) {
132133
</div>
133134
</div>
134135

136+
{task.maturity ? (
137+
<div className="flex items-center justify-between gap-3">
138+
<div className="text-slate-600">Maturity</div>
139+
<MaturityBadge maturity={task.maturity} className="shrink-0" />
140+
</div>
141+
) : null}
142+
135143
{task.psyflow_version ? (
136144
<div className="flex items-center justify-between gap-3">
137145
<div className="text-slate-600">PsyFlow</div>

src/components/maturity-badge.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import clsx from "@/components/utils/clsx";
2+
3+
export function formatMaturityLabel(maturity: string) {
4+
const k = String(maturity ?? "").trim().toLowerCase().replace(/\s+/g, "_");
5+
const s = k.replace(/[_-]+/g, " ").trim();
6+
if (!s) return "";
7+
return s.charAt(0).toUpperCase() + s.slice(1);
8+
}
9+
10+
function maturityClasses(maturity: string) {
11+
const k = String(maturity ?? "").trim().toLowerCase().replace(/\s+/g, "_");
12+
13+
if (k === "smoke_tested") {
14+
return "border-amber-200 bg-amber-50 text-amber-950";
15+
}
16+
17+
if (k === "piloted") {
18+
return "border-lime-200 bg-lime-50 text-lime-950";
19+
}
20+
21+
if (k === "alpha") {
22+
return "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-950";
23+
}
24+
25+
if (k === "beta") {
26+
return "border-sky-200 bg-sky-50 text-sky-950";
27+
}
28+
29+
if (k === "validated" || k === "production") {
30+
return "border-emerald-200 bg-emerald-50 text-emerald-950";
31+
}
32+
33+
return "border-slate-200 bg-slate-50 text-slate-800";
34+
}
35+
36+
export function MaturityBadge({
37+
maturity,
38+
className
39+
}: {
40+
maturity: string;
41+
className?: string;
42+
}) {
43+
const label = formatMaturityLabel(maturity);
44+
if (!label) return null;
45+
46+
return (
47+
<span
48+
title={`Maturity: ${label}`}
49+
className={clsx(
50+
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold",
51+
maturityClasses(maturity),
52+
className
53+
)}
54+
>
55+
{label}
56+
</span>
57+
);
58+
}

src/components/task-card.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { TaskIndexItem, TaskTagFacet } from "@/lib/task-index";
55
import { taskLinks } from "@/lib/task-index";
66
import { formatShortDate } from "@/lib/format";
77
import { TagChip } from "@/components/tag-chip";
8+
import { MaturityBadge } from "@/components/maturity-badge";
89
import { IconArrowRight, IconDownload, IconGithub, IconPlay } from "@/components/icons";
910

1011
function TagRow({
@@ -52,14 +53,15 @@ export function TaskCard({
5253
<div className="group flex h-full flex-col rounded-2xl border border-slate-200 bg-white/85 p-5 shadow-sm transition-colors hover:border-brand-200 hover:bg-white">
5354
<div className="flex items-start justify-between gap-4">
5455
<div className="min-w-0">
55-
<div className="flex items-center gap-2">
56+
<div className="flex flex-wrap items-center gap-2">
5657
<Link
5758
className="tb-focus-ring rounded-md font-heading text-base font-semibold tracking-tight text-slate-900 hover:text-brand-900"
5859
href={`/tasks/${encodeURIComponent(task.repo)}`}
5960
>
6061
{task.repo}
6162
</Link>
6263
<IconArrowRight className="size-4 text-slate-400 transition-colors group-hover:text-brand-700" />
64+
{task.maturity ? <MaturityBadge maturity={task.maturity} /> : null}
6365
</div>
6466
<div className="mt-2 text-sm leading-6 text-slate-700">
6567
{task.short_description || "No description provided."}

0 commit comments

Comments
 (0)