// Names and small validators shared by the generators. Pure; no I/O. /** A usage error: shown to the user as-is, exit code 1, no stack. */ export class UsageError extends Error {} /** `Hide Trends!` -> `hide-trends` (ASCII letters and digits, dashes between). */ export function slugify(value, max = 64) { return String(value) .normalize('NFKD') .replace(/\p{M}/gu, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, max) .replace(/-+$/, ''); } /** `hide trends 2` -> `HideTrends2`; always a valid JS/Rust type identifier. */ export function pascalCase(value) { const words = String(value).normalize('NFKD').replace(/\p{M}/gu, '').replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean); const name = words.map(word => word[0].toUpperCase() + word.slice(1)).join(''); return /^[A-Za-z]/.test(name) ? name : `Plugin${name}`; } /** `wayBetter for Kick` -> `wayBetterForKick` (camelCase, for `data-*` keys). */ export function camelCase(value) { const name = pascalCase(value); return name[0].toLowerCase() + name.slice(1); } /** * A value for a `@name` / `@description` / ... plugin header line, or a theme string. The plugin * store reads each header line up to its end, drops control characters and keeps 300 characters * (waybetter-host `plugins::parse_meta`), so values are one line and cannot end the comment. */ export function headerValue(label, value, { max = 300, required = false } = {}) { const text = String(value ?? '').trim(); if (required && !text) throw new UsageError(`${label} is required.`); if (/[\u0000-\u001f\u007f]/.test(text)) throw new UsageError(`${label} must be one line of text.`); if (text.includes('*/')) throw new UsageError(`${label} cannot contain "*/".`); if (text.length > max) throw new UsageError(`${label} is longer than ${max} characters.`); return text; } /** Semver-ish plugin version (`1.0.0`, `0.3.1-beta.2`). */ export function versionValue(value) { const text = String(value ?? '1.0.0').trim() || '1.0.0'; if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(text)) throw new UsageError(`Version "${text}" is not like 1.0.0.`); return text; } export const HEX = /^#[0-9a-fA-F]{6}$/; /** `#abc` / `abc123` / `#ABC123` -> `#aabbcc` style lowercase six-digit hex. */ export function hexValue(label, value) { let text = String(value ?? '').trim().toLowerCase(); if (!text.startsWith('#')) text = `#${text}`; const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(text); if (short) text = `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`; if (!HEX.test(text)) throw new UsageError(`${label} must be a hex color like #1d9bf0 (got "${value}").`); return text; } /** A JS/TS string literal. */ export const str = value => JSON.stringify(String(value)); /** A Rust string literal (JSON escapes are valid Rust escapes for the characters we allow). */ export function rustStr(value) { return JSON.stringify(String(value)).replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => `\\u{${hex}}`); } /** Joins lines with `\n` and ends the file with one newline. */ export const lines = (...parts) => `${parts.flat().join('\n').replace(/\n+$/, '')}\n`;