Session and theme clustering, EDL validation and timeline mapping, the EDL-to-ffmpeg compiler with caption files and filtergraph escaping, teaser selection and learning, the compliance gate with compliance-only auto-fix, and the marketing helpers (UTM, hashtags, CSV metrics, intro proposals, brand-kit checks, upload checklist). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
533 lines
19 KiB
TypeScript
533 lines
19 KiB
TypeScript
import type {
|
||
BrandKit,
|
||
MetricCsvMapping,
|
||
MotionTemplateInfo,
|
||
MotionTemplateRef,
|
||
PlatformProfile,
|
||
Utm,
|
||
} from "@rakazo/contracts";
|
||
|
||
const UTM_KEYS: Array<[keyof Utm, string]> = [
|
||
["source", "utm_source"],
|
||
["medium", "utm_medium"],
|
||
["campaign", "utm_campaign"],
|
||
["content", "utm_content"],
|
||
["term", "utm_term"],
|
||
];
|
||
|
||
/**
|
||
* Append UTM parameters to an http(s) target. Rejects other schemes. Existing
|
||
* query parameters keep their exact encoding; existing utm_* values for the
|
||
* keys being set are replaced, and the fragment stays last.
|
||
*/
|
||
export function buildTrackingUrl(targetUrl: string, utm: Utm): string {
|
||
let url: URL;
|
||
try {
|
||
url = new URL(targetUrl.trim());
|
||
} catch {
|
||
throw new Error("tracking target is not a valid URL");
|
||
}
|
||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||
throw new Error("tracking target must use http or https");
|
||
}
|
||
const setting = UTM_KEYS.filter(([key]) => utm[key]?.trim());
|
||
const replaced = new Set(setting.map(([, name]) => name));
|
||
const kept = url.search
|
||
.replace(/^\?/, "")
|
||
.split("&")
|
||
.filter((part) => {
|
||
if (!part) return false;
|
||
const rawKey = part.split("=")[0] ?? "";
|
||
let key = rawKey;
|
||
try {
|
||
key = decodeURIComponent(rawKey.replace(/\+/g, " "));
|
||
} catch {
|
||
// keep undecodable keys as they are
|
||
}
|
||
return !replaced.has(key);
|
||
});
|
||
const added = setting.map(
|
||
([key, name]) => `${name}=${encodeURIComponent((utm[key] as string).trim())}`,
|
||
);
|
||
const query = [...kept, ...added].join("&");
|
||
return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
|
||
}
|
||
|
||
/**
|
||
* Normalize to "#tag": strip leading #, drop spaces and punctuation (letters,
|
||
* digits and _ stay, in any script), dedupe case-insensitively, and cap.
|
||
*/
|
||
export function normalizeHashtags(tags: string[], limit: number): string[] {
|
||
const seen = new Set<string>();
|
||
const out: string[] = [];
|
||
for (const raw of tags) {
|
||
const tag = raw.normalize("NFC").replace(/[^\p{L}\p{N}_]/gu, "");
|
||
if (!tag) continue;
|
||
const key = tag.toLocaleLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
out.push(`#${tag}`);
|
||
if (out.length >= Math.max(0, limit)) break;
|
||
}
|
||
return out.slice(0, Math.max(0, limit));
|
||
}
|
||
|
||
export interface ParsedMetricRow {
|
||
row: number;
|
||
publicationKey: string;
|
||
observedAt: Date;
|
||
views: number;
|
||
clicks: number;
|
||
conversions: number;
|
||
revenueCents: number;
|
||
currency: string;
|
||
}
|
||
|
||
/** RFC 4180 records; quoted fields may hold delimiters, quotes ("") and newlines. */
|
||
export function parseCsvRecords(csv: string, delimiter: string): string[][] {
|
||
const text = csv.replace(/^/, "");
|
||
const records: string[][] = [];
|
||
let record: string[] = [];
|
||
let field = "";
|
||
let quoted = false;
|
||
let i = 0;
|
||
while (i < text.length) {
|
||
const char = text[i]!;
|
||
if (quoted) {
|
||
if (char === '"') {
|
||
if (text[i + 1] === '"') {
|
||
field += '"';
|
||
i += 2;
|
||
continue;
|
||
}
|
||
quoted = false;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
field += char;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (char === '"' && field.length === 0) {
|
||
quoted = true;
|
||
i += 1;
|
||
} else if (char === delimiter) {
|
||
record.push(field);
|
||
field = "";
|
||
i += 1;
|
||
} else if (char === "\r" || char === "\n") {
|
||
record.push(field);
|
||
records.push(record);
|
||
record = [];
|
||
field = "";
|
||
i += char === "\r" && text[i + 1] === "\n" ? 2 : 1;
|
||
} else {
|
||
field += char;
|
||
i += 1;
|
||
}
|
||
}
|
||
if (field.length > 0 || record.length > 0) {
|
||
record.push(field);
|
||
records.push(record);
|
||
}
|
||
return records;
|
||
}
|
||
|
||
const CURRENCY_SYMBOLS: Record<string, string> = { "€": "EUR", $: "USD", "£": "GBP", "¥": "JPY" };
|
||
|
||
/** Whole, non-negative count with optional thousands separators. */
|
||
function parseCount(raw: string): number | null {
|
||
const value = raw.trim().replace(/[\s ']/g, "");
|
||
if (value === "") return 0;
|
||
if (/^\d+$/.test(value)) return Number(value);
|
||
if (/^\d{1,3}([.,]\d{3})+$/.test(value) && new Set(value.match(/[.,]/g)).size === 1) {
|
||
return Number(value.replace(/[.,]/g, ""));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Money in major units to cents. With both "," and "." the later one is the
|
||
* decimal separator; a single separator followed by one or two digits is
|
||
* decimal, by exactly three digits after 1–999 it groups thousands ("1,234"),
|
||
* otherwise it is decimal ("0.995"). Extra decimals round half up.
|
||
*/
|
||
export function parseMoneyToCents(raw: string): { cents: number; currency: string | null } | null {
|
||
let value = raw.trim();
|
||
let currency: string | null = null;
|
||
for (const [symbol, code] of Object.entries(CURRENCY_SYMBOLS)) {
|
||
if (value.includes(symbol)) {
|
||
currency = code;
|
||
value = value.replace(symbol, "");
|
||
}
|
||
}
|
||
const code = value.match(/[A-Za-z]{3}/)?.[0];
|
||
if (code) {
|
||
currency = code.toUpperCase();
|
||
value = value.replace(code, "");
|
||
}
|
||
value = value.replace(/[\s ']/g, "");
|
||
if (value === "") return { cents: 0, currency };
|
||
if (!/^\d[\d.,]*$/.test(value)) return null;
|
||
const lastComma = value.lastIndexOf(",");
|
||
const lastDot = value.lastIndexOf(".");
|
||
let integer = value;
|
||
let fraction = "";
|
||
if (lastComma >= 0 && lastDot >= 0) {
|
||
const decimal = Math.max(lastComma, lastDot);
|
||
integer = value.slice(0, decimal);
|
||
fraction = value.slice(decimal + 1);
|
||
} else if (lastComma >= 0 || lastDot >= 0) {
|
||
const separator = lastComma >= 0 ? "," : ".";
|
||
const parts = value.split(separator);
|
||
const tail = parts.at(-1)!;
|
||
const decimal =
|
||
parts.length === 2 &&
|
||
tail.length >= 1 &&
|
||
(tail.length <= 2 || !/^[1-9]\d{0,2}$/.test(parts[0]!));
|
||
if (decimal) {
|
||
integer = parts[0]!;
|
||
fraction = tail;
|
||
} else if (parts.slice(1).every((part) => part.length === 3)) {
|
||
integer = parts.join("");
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
integer = integer.replace(/[.,]/g, "");
|
||
if (!/^\d+$/.test(integer) || !/^\d*$/.test(fraction)) return null;
|
||
const twoDigits = `${fraction}00`.slice(0, 2);
|
||
const roundUp = fraction.length > 2 && Number(fraction[2]) >= 5 ? 1 : 0;
|
||
return { cents: Number(integer) * 100 + Number(twoDigits) + roundUp, currency };
|
||
}
|
||
|
||
/** ISO dates and times, YYYY/MM/DD, and DD.MM.YYYY. Ambiguous slash dates are rejected. */
|
||
function parseDate(raw: string): Date | null {
|
||
const value = raw.trim();
|
||
let match = value.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/);
|
||
if (match) return utcDate(Number(match[1]), Number(match[2]), Number(match[3]));
|
||
match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||
if (match) return utcDate(Number(match[3]), Number(match[2]), Number(match[1]));
|
||
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) {
|
||
const date = new Date(value.replace(" ", "T"));
|
||
return Number.isNaN(date.getTime()) ? null : date;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function utcDate(year: number, month: number, day: number): Date | null {
|
||
const date = new Date(Date.UTC(year, month - 1, day));
|
||
return date.getUTCFullYear() === year &&
|
||
date.getUTCMonth() === month - 1 &&
|
||
date.getUTCDate() === day
|
||
? date
|
||
: null;
|
||
}
|
||
|
||
/** RFC 4180 CSV parsing with the saved column mapping. Rows count from the header as row 1. */
|
||
export function parseMetricsCsv(
|
||
csv: string,
|
||
mapping: MetricCsvMapping,
|
||
): { rows: ParsedMetricRow[]; skipped: Array<{ row: number; reason: string }> } {
|
||
const records = parseCsvRecords(csv, mapping.delimiter ?? ",");
|
||
const header = records[0];
|
||
const rows: ParsedMetricRow[] = [];
|
||
const skipped: Array<{ row: number; reason: string }> = [];
|
||
if (!header) return { rows, skipped: [{ row: 1, reason: "file is empty" }] };
|
||
const names = header.map((name) => name.trim());
|
||
const column = (name: string | undefined): number => {
|
||
if (!name) return -1;
|
||
const exact = names.indexOf(name.trim());
|
||
if (exact >= 0) return exact;
|
||
const lower = name.trim().toLowerCase();
|
||
return names.findIndex((n) => n.toLowerCase() === lower);
|
||
};
|
||
const columns = mapping.columns;
|
||
const index = {
|
||
publication: column(columns.publication),
|
||
observedAt: column(columns.observedAt),
|
||
views: column(columns.views),
|
||
clicks: column(columns.clicks),
|
||
conversions: column(columns.conversions),
|
||
revenue: column(columns.revenue),
|
||
currency: column(columns.currency),
|
||
};
|
||
const missing = (Object.keys(index) as Array<keyof typeof index>).filter(
|
||
(key) => columns[key] !== undefined && index[key] < 0,
|
||
);
|
||
if (missing.length > 0) {
|
||
return {
|
||
rows,
|
||
skipped: [
|
||
{ row: 1, reason: `missing columns: ${missing.map((k) => columns[k]).join(", ")}` },
|
||
],
|
||
};
|
||
}
|
||
const defaultCurrency = (mapping.defaultCurrency ?? "EUR").toUpperCase();
|
||
|
||
for (const [offset, record] of records.slice(1).entries()) {
|
||
const row = offset + 2;
|
||
if (record.every((field) => field.trim() === "")) continue;
|
||
const cell = (i: number) => (i >= 0 ? (record[i] ?? "") : "");
|
||
const publicationKey = cell(index.publication).trim();
|
||
if (!publicationKey) {
|
||
skipped.push({ row, reason: "publication is empty" });
|
||
continue;
|
||
}
|
||
const observedAt = parseDate(cell(index.observedAt));
|
||
if (!observedAt) {
|
||
skipped.push({ row, reason: "date is not recognized" });
|
||
continue;
|
||
}
|
||
const counts = {
|
||
views: parseCount(cell(index.views)),
|
||
clicks: parseCount(cell(index.clicks)),
|
||
conversions: parseCount(cell(index.conversions)),
|
||
};
|
||
const invalid = (Object.keys(counts) as Array<keyof typeof counts>).find(
|
||
(key) => counts[key] === null,
|
||
);
|
||
if (invalid) {
|
||
skipped.push({ row, reason: `${invalid} is not a whole number` });
|
||
continue;
|
||
}
|
||
let revenueCents = 0;
|
||
let currency: string | null = null;
|
||
const revenueRaw = cell(index.revenue);
|
||
if (revenueRaw.trim()) {
|
||
if (/^\s*-/.test(revenueRaw)) {
|
||
skipped.push({ row, reason: "revenue is negative" });
|
||
continue;
|
||
}
|
||
if (mapping.revenueUnit === "cents") {
|
||
const value = parseCount(revenueRaw);
|
||
if (value === null) {
|
||
skipped.push({ row, reason: "revenue is not a whole number" });
|
||
continue;
|
||
}
|
||
revenueCents = value;
|
||
} else {
|
||
const money = parseMoneyToCents(revenueRaw);
|
||
if (!money) {
|
||
skipped.push({ row, reason: "revenue is not a number" });
|
||
continue;
|
||
}
|
||
revenueCents = money.cents;
|
||
currency = money.currency;
|
||
}
|
||
}
|
||
const currencyCell = cell(index.currency).trim().toUpperCase();
|
||
if (currencyCell && !/^[A-Z]{3}$/.test(currencyCell)) {
|
||
skipped.push({ row, reason: "currency is not a 3-letter code" });
|
||
continue;
|
||
}
|
||
rows.push({
|
||
row,
|
||
publicationKey,
|
||
observedAt,
|
||
views: counts.views ?? 0,
|
||
clicks: counts.clicks ?? 0,
|
||
conversions: counts.conversions ?? 0,
|
||
revenueCents,
|
||
currency: currencyCell || currency || defaultCurrency,
|
||
});
|
||
}
|
||
return { rows, skipped };
|
||
}
|
||
|
||
const SHORT_FORM_MAX_MS = 180_000;
|
||
const MAX_INTRO_MS = 4000;
|
||
|
||
/** Vertical profiles capped at three minutes: the hook must come before any branding. */
|
||
export function isShortForm(profile: PlatformProfile): boolean {
|
||
return (
|
||
profile.video.aspectRatios.includes("9:16") && profile.duration.hardMaxMs <= SHORT_FORM_MAX_MS
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Whether the brand intro may open the video. Short-form vertical profiles
|
||
* always put the hook first (the brand appears as watermark or end card).
|
||
* Elsewhere the intro may take at most 4 s and a tenth of the maximum length.
|
||
*/
|
||
export function introFits(profile: PlatformProfile, introMs: number): boolean {
|
||
if (introMs <= 0) return true;
|
||
if (isShortForm(profile)) return false;
|
||
return introMs <= Math.min(MAX_INTRO_MS, profile.duration.hardMaxMs / 10);
|
||
}
|
||
|
||
const TONE_STYLES: Array<{ pattern: RegExp; keywords: string[] }> = [
|
||
{
|
||
pattern: /energ|bold|dynamic|loud|wild|sport|fast|laut|schnell|kraft/i,
|
||
keywords: ["glitch", "energy", "kinetic"],
|
||
},
|
||
{
|
||
pattern: /elegan|calm|luxur|soft|premium|ruhig|edel|sanft|warm/i,
|
||
keywords: ["elegant", "fade", "minimal"],
|
||
},
|
||
{
|
||
pattern: /minimal|clean|simple|schlicht|klar|modern/i,
|
||
keywords: ["minimal", "logo", "fade"],
|
||
},
|
||
{ pattern: /playful|fun|witzig|verspielt|bunt/i, keywords: ["kinetic", "typo", "glitch"] },
|
||
];
|
||
|
||
const PARAM_SOURCES: Record<string, (kit: BrandKit) => string | undefined> = {
|
||
primarycolor: (kit) => kit.colors.primary,
|
||
secondarycolor: (kit) => kit.colors.secondary,
|
||
accentcolor: (kit) => kit.colors.accent,
|
||
textcolor: (kit) => kit.colors.text,
|
||
backgroundcolor: (kit) => kit.colors.background,
|
||
brandname: (kit) => kit.name,
|
||
title: (kit) => kit.name,
|
||
name: (kit) => kit.name,
|
||
logoartifactid: (kit) => kit.logoArtifactIds[0],
|
||
logo: (kit) => kit.logoArtifactIds[0],
|
||
fontfamily: (kit) => (kit.fonts.find((f) => f.role === "heading") ?? kit.fonts[0])?.family,
|
||
soundartifactid: (kit) => kit.musicBeds.find((bed) => bed.commercialUse)?.artifactId,
|
||
sound: (kit) => kit.musicBeds.find((bed) => bed.commercialUse)?.artifactId,
|
||
};
|
||
|
||
const VARIANT_DURATIONS = [2500, 2000, 3000, 3500, 1500, 4000];
|
||
|
||
/**
|
||
* Deterministic intro proposals from the brand kit, tone, and available
|
||
* templates: intro templates are ranked by how well their id and name match
|
||
* the tone's style keywords (then by id), each proposal uses a distinct
|
||
* template while there are enough, and durations stay within 1.5–4 s and the
|
||
* template's own limits. Parameters are filled from the kit by name.
|
||
*/
|
||
export function proposeIntroVariants(
|
||
kit: BrandKit,
|
||
templates: MotionTemplateInfo[],
|
||
count: number,
|
||
): Array<{ template: MotionTemplateRef; rationale: string }> {
|
||
const intros = templates
|
||
.filter((t) => t.kind === "intro")
|
||
.sort((a, b) => a.id.localeCompare(b.id));
|
||
if (intros.length === 0 || count <= 0) return [];
|
||
const tone = `${kit.tone ?? ""} ${kit.audience ?? ""}`;
|
||
const keywords = TONE_STYLES.filter((style) => style.pattern.test(tone)).flatMap(
|
||
(style) => style.keywords,
|
||
);
|
||
const affinity = (template: MotionTemplateInfo) => {
|
||
const haystack = `${template.id} ${template.name}`.toLowerCase();
|
||
return keywords.reduce(
|
||
(score, keyword, index) => score + (haystack.includes(keyword) ? keywords.length - index : 0),
|
||
0,
|
||
);
|
||
};
|
||
const ranked = [...intros].sort((a, b) => affinity(b) - affinity(a) || a.id.localeCompare(b.id));
|
||
|
||
return Array.from({ length: count }, (_, index) => {
|
||
const template = ranked[index % ranked.length]!;
|
||
const round = Math.floor(index / ranked.length);
|
||
const wanted = VARIANT_DURATIONS[(index + round) % VARIANT_DURATIONS.length]!;
|
||
const durationMs = Math.min(
|
||
Math.max(wanted, 1500, template.minDurationMs),
|
||
Math.min(4000, template.maxDurationMs),
|
||
);
|
||
const params: Record<string, string | number | boolean> = {};
|
||
for (const param of template.params) {
|
||
const source = PARAM_SOURCES[param.name.toLowerCase().replace(/[^a-z]/g, "")];
|
||
const value = source?.(kit);
|
||
if (value !== undefined) params[param.name] = value.slice(0, 200);
|
||
}
|
||
const matched = affinity(template) > 0 && kit.tone ? ` matches the tone "${kit.tone}"` : "";
|
||
return {
|
||
template: { templateId: template.id, params, durationMs: Math.max(0, durationMs) },
|
||
rationale: `${template.name}${matched}, ${(durationMs / 1000).toFixed(1)} s`,
|
||
};
|
||
});
|
||
}
|
||
|
||
function luminance(hex: string): number {
|
||
const channel = (offset: number) => {
|
||
const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||
};
|
||
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||
}
|
||
|
||
/** WCAG contrast ratio of two #RRGGBB colours. */
|
||
export function contrastRatio(a: string, b: string): number {
|
||
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
||
return (light + 0.05) / (dark + 0.05);
|
||
}
|
||
|
||
const MIN_CONTRAST = 4.5;
|
||
|
||
/** Structural brand-kit checks beyond the schema (contrast, duplicate presets). */
|
||
export function validateBrandKit(kit: BrandKit): string[] {
|
||
const warnings: string[] = [];
|
||
const ratio = contrastRatio(kit.colors.text, kit.colors.background);
|
||
if (ratio < MIN_CONTRAST) {
|
||
warnings.push(`text on background has contrast ${ratio.toFixed(1)}:1, below ${MIN_CONTRAST}:1`);
|
||
}
|
||
const seen = new Set<string>();
|
||
for (const preset of kit.captionPresets) {
|
||
if (seen.has(preset.id)) warnings.push(`caption preset id "${preset.id}" is used twice`);
|
||
seen.add(preset.id);
|
||
if (preset.backgroundColor) {
|
||
const presetRatio = contrastRatio(preset.textColor, preset.backgroundColor);
|
||
if (presetRatio < MIN_CONTRAST) {
|
||
warnings.push(
|
||
`caption preset "${preset.id}" has contrast ${presetRatio.toFixed(1)}:1, below ${MIN_CONTRAST}:1`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
for (const bed of kit.musicBeds) {
|
||
if (!bed.commercialUse)
|
||
warnings.push(`music bed "${bed.title}" is not licensed for commercial use`);
|
||
}
|
||
for (const font of kit.fonts) {
|
||
if (!font.license?.trim()) warnings.push(`font "${font.family}" has no license note`);
|
||
}
|
||
if (kit.watermark && !kit.watermark.artifactId) warnings.push("watermark has no image");
|
||
return warnings;
|
||
}
|
||
|
||
/** Upload checklist markdown for an export bundle. */
|
||
export function uploadChecklist(input: {
|
||
profile: PlatformProfile;
|
||
aiAltered: boolean;
|
||
hasCaptions: boolean;
|
||
trackingUrl: string | null;
|
||
}): string {
|
||
const { profile } = input;
|
||
const items: string[] = ["Upload the video file and choose the poster as cover image."];
|
||
if (input.aiAltered && profile.disclosure.aiAlteredLabelRequired) {
|
||
items.push(
|
||
"Set the platform's AI-generated or altered content label: this video contains AI alterations.",
|
||
);
|
||
}
|
||
if (profile.disclosure.ageRestrictedFlag) items.push("Mark the upload as age-restricted.");
|
||
if (profile.disclosure.paidPartnershipLabel) {
|
||
items.push("Turn on the paid partnership label if this post is sponsored.");
|
||
}
|
||
if (input.trackingUrl) {
|
||
const link = `<${input.trackingUrl}>`;
|
||
if (profile.text.linkPolicy === "allowed") items.push(`Add the tracking link: ${link}`);
|
||
else if (profile.text.linkPolicy === "bio_only") {
|
||
items.push(`Put the tracking link in the profile bio, not in the caption: ${link}`);
|
||
} else items.push("Do not add links: this platform does not allow them.");
|
||
} else if (profile.text.linkPolicy === "forbidden") {
|
||
items.push("Do not add links: this platform does not allow them.");
|
||
}
|
||
if (input.hasCaptions) {
|
||
items.push("Upload the SRT or VTT subtitles, or confirm the burned-in captions are readable.");
|
||
} else if (profile.captions.required) {
|
||
items.push("Captions are required on this platform: add them before publishing.");
|
||
}
|
||
const limits = [
|
||
profile.text.titleMaxChars ? `title up to ${profile.text.titleMaxChars} characters` : null,
|
||
`caption up to ${profile.text.captionMaxChars} characters`,
|
||
`at most ${profile.text.hashtagLimit} hashtags`,
|
||
].filter(Boolean);
|
||
items.push(`Paste the caption from caption.txt (${limits.join(", ")}).`);
|
||
return `# Upload checklist: ${profile.name.replace(/[\r\n]+/g, " ")}\n\n${items
|
||
.map((item) => `- [ ] ${item}`)
|
||
.join("\n")}\n`;
|
||
}
|