field-guide / src /lib /readme.ts
pngwn
fix
fdf3925
Raw
History Blame
6.98 kB
/* ============================================================
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<string>;
sponsors: Set<string>;
achievements: Set<string>;
} {
const tracks = new Set<string>();
const sponsors = new Set<string>();
const achievements = new Set<string>();
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))
};
}