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))
	};
}