File size: 6,978 Bytes
a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab fdf3925 a8647ab | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | /* ============================================================
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))
};
}
|