/* ============================================================ readme.ts — fetch + parse + regenerate a Build Small Space README Tracks and badges a participant wants to be considered for are encoded as namespaced entries in the README frontmatter `tags:` list: track:backyard badge:tiny ... Regeneration is surgical: only the `tags:` block is rewritten, so every other line of the participant's frontmatter (and the whole body) is kept byte-for-byte — which keeps the "what changed" diff clean. ============================================================ */ export const SPACE_OWNER = 'build-small-hackathon'; /** Raw README URL for a Space in the hackathon org. Public, no token needed. */ export function rawReadmeUrl(space: string): string { return `https://huggingface.co/spaces/${SPACE_OWNER}/${encodeURIComponent(space)}/raw/main/README.md`; } /** * Pull the bare space name out of whatever the user pasted — a full URL, * `owner/space`, or a leading-slash path all collapse to just `space`. */ export function normalizeSpaceName(input: string): string { let s = input.trim(); // strip a pasted URL down to its path s = s.replace(/^https?:\/\/huggingface\.co\//i, ''); s = s.replace(/^\/+/, ''); s = s.replace(/^spaces\//i, ''); s = s.replace(new RegExp(`^${SPACE_OWNER}/`, 'i'), ''); // drop anything after the space segment (/tree/main, /blob/..., trailing slash) s = s.split('/')[0]; return s.trim(); } // The tool only manages the tags a participant self-declares: their tracks, // the sponsor prizes they're entering, and their no-prize achievement badges. // Anything else in `tags:` is treated as non-managed and preserved untouched. export const MANAGED_PREFIXES = ['track:', 'sponsor:', 'achievement:'] as const; export const isManagedTag = (t: string) => MANAGED_PREFIXES.some((p) => t.startsWith(p)); export const trackTag = (id: string) => `track:${id}`; export const sponsorTag = (id: string) => `sponsor:${id}`; export const achievementTag = (id: string) => `achievement:${id}`; export interface ParsedReadme { hasFrontmatter: boolean; /** YAML text between the `---` fences (fences excluded). */ frontmatter: string; /** Everything after the closing fence (or the whole doc if no frontmatter). */ body: string; /** Tags parsed from the frontmatter `tags:` declaration. */ tags: string[]; /** Indentation of the existing `tags:` list items, so we can round-trip it. */ tagsIndent: string; /** Char range of the whole `tags:` declaration within `frontmatter`, if present. */ tagsRange: { start: number; end: number } | null; } const FRONTMATTER_RE = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/; const unquote = (s: string) => s.trim().replace(/^['"]|['"]$/g, ''); /** Locate and parse the `tags:` declaration inside a frontmatter block. */ function findTagsBlock( fm: string ): { tags: string[]; indent: string; start: number; end: number } | null { const lines = fm.split('\n'); const offsets: number[] = []; let acc = 0; for (const l of lines) { offsets.push(acc); acc += l.length + 1; // + the '\n' that split removed } for (let i = 0; i < lines.length; i++) { const m = /^tags:[ \t]*(.*)$/.exec(lines[i]); if (!m) continue; const start = offsets[i]; const inline = m[1].trim(); // flow form on the same line: tags: [a, b, c] if (inline.startsWith('[')) { const tags = inline .replace(/^\[|\]$/g, '') .split(',') .map(unquote) .filter(Boolean); return { tags, indent: ' ', start, end: offsets[i] + lines[i].length }; } // single scalar on the same line: tags: foo if (inline) { return { tags: [unquote(inline)], indent: ' ', start, end: offsets[i] + lines[i].length }; } // block form: subsequent "- item" lines. YAML lets the sequence sit at the // same indent as `tags:` (no extra indent) or be indented under it — accept // either, and remember the indent the first item used so we round-trip it. const tags: string[] = []; let indent = ' '; let last = i; for (let j = i + 1; j < lines.length; j++) { const lm = /^([ \t]*)-[ \t]+(.*)$/.exec(lines[j]); if (!lm) break; // any non list-item line (including blank) ends the block if (tags.length === 0) indent = lm[1]; // first item sets the indent tags.push(unquote(lm[2])); last = j; } return { tags, indent, start, end: offsets[last] + lines[last].length }; } return null; } export function parseReadme(raw: string): ParsedReadme { const m = FRONTMATTER_RE.exec(raw); if (!m) { return { hasFrontmatter: false, frontmatter: '', body: raw, tags: [], tagsIndent: ' ', tagsRange: null }; } const frontmatter = m[1]; const body = raw.slice(m[0].length); const block = findTagsBlock(frontmatter); return { hasFrontmatter: true, frontmatter, body, tags: block?.tags ?? [], tagsIndent: block?.indent ?? ' ', tagsRange: block ? { start: block.start, end: block.end } : null }; } /** Split a README's existing tags into selected track / sponsor / achievement ids. */ export function splitManaged(tags: string[]): { tracks: Set; sponsors: Set; achievements: Set; } { const tracks = new Set(); const sponsors = new Set(); const achievements = new Set(); for (const t of tags) { if (t.startsWith('track:')) tracks.add(t.slice('track:'.length)); else if (t.startsWith('sponsor:')) sponsors.add(t.slice('sponsor:'.length)); else if (t.startsWith('achievement:')) achievements.add(t.slice('achievement:'.length)); } return { tracks, sponsors, achievements }; } /** The `tags:` YAML block for a list of tags, in block-list form. */ export const tagsBlock = (tags: string[], indent = ' ') => tags.length ? 'tags:\n' + tags.map((t) => `${indent}- ${t}`).join('\n') : 'tags:'; /** * Rewrite the README so its `tags:` list holds exactly the participant's * non-managed tags (preserved as-is) followed by `selectedTags` * (already namespaced + ordered by the caller). */ export function regenerateReadme(raw: string, selectedTags: string[]): string { const parsed = parseReadme(raw); const preserved = parsed.tags.filter((t) => !isManagedTag(t)); const nextTags = [...preserved, ...selectedTags]; const block = tagsBlock(nextTags, parsed.tagsIndent); if (!parsed.hasFrontmatter) { return `---\n${block}\n---\n\n${parsed.body}`; } let fm = parsed.frontmatter; if (parsed.tagsRange) { fm = fm.slice(0, parsed.tagsRange.start) + block + fm.slice(parsed.tagsRange.end); } else { fm = fm.length ? `${fm}\n${block}` : block; } return `---\n${fm}\n---\n${parsed.body}`; } /** Added / removed tags between the loaded README and the regenerated one. */ export function diffTags( original: string[], next: string[] ): { added: string[]; removed: string[] } { const o = new Set(original); const n = new Set(next); return { added: next.filter((t) => !o.has(t)), removed: original.filter((t) => !n.has(t)) }; }