43 lines · 1.6 KB
/** 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 [];