/** Repository topics: short, lowercase, hyphenated tags an admin sets for discovery (GitHub's rules). */ export const MAX_TOPICS = 20; export const MAX_TOPIC_LENGTH = 50; const TOPIC = /^[a-z0-9][a-z0-9-]*$/; export class TopicError extends Error {} /** * Normalizes user input (an array, or one string split on commas and whitespace): lowercased, spaces and * underscores become hyphens, duplicates dropped. Throws TopicError naming the first invalid topic. */ export function normalizeTopics(input: string | readonly string[]): string[] { const raw = typeof input === "string" ? input.split(/[\s,]+/) : input; const out: string[] = []; for (const value of raw) { const topic = value .trim() .toLowerCase() .replace(/[\s_]+/g, "-"); if (!topic) continue; if (topic.length > MAX_TOPIC_LENGTH) throw new TopicError(`Topics are at most ${MAX_TOPIC_LENGTH} characters`); if (!TOPIC.test(topic)) { throw new TopicError( `"${topic}": topics use lowercase letters, numbers and hyphens, and start with a letter or number`, ); } if (!out.includes(topic)) out.push(topic); } if (out.length > MAX_TOPICS) throw new TopicError(`A repository has at most ${MAX_TOPICS} topics`); return out; } /** The stored JSON column, tolerating anything malformed (it was written by normalizeTopics). */ export function parseTopics(stored: string | null | undefined): string[] { if (!stored) return []; try { const parsed: unknown = JSON.parse(stored); return Array.isArray(parsed) ? parsed.filter((topic): topic is string => typeof topic === "string") : []; } catch { return []; } }