Content calendar
@contentstudio/content-calendar
Plan a few weeks of content across LinkedIn, a newsletter, a blog, and more. A script lays out dated slots per channel with your content pillars rotated evenly, then Claude fills in specific topics and hooks you can actually write. Exports a CSV for your sheet or planner.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Lays out a content calendar: dated slots per channel, with content pillars rotated evenly.
- 3// Usage: node scripts/calendar.mjs [out-dir, default content/calendar]
- 4// --start YYYY-MM-DD [--weeks 4]
- 5// [--cadence "linkedin:mon,wed,fri newsletter:thu blog:tue"]
- 6// [--pillars "Product tips,Customer stories,Behind the scenes"]
- 7// [--skip 2026-11-26,2026-12-25] [--name content-calendar]
- 8// Writes: <out-dir>/<name>.csv, <out-dir>/<name>.md, <out-dir>/<name>.json
- 9// Topics are left blank for a person (or Claude) to fill in.
- 10import { existsSync, mkdirSync, writeFileSync } from "node:fs";
- 11import { join } from "node:path";
- 13const args = process.argv.slice(2);
- 14function flag(name, fallback) {
- 15 const i = args.indexOf(name);
- 16 if (i === -1) return fallback;
- 17 const value = args[i + 1] ?? "";
- 18 args.splice(i, 2);
- 19 return value;
- 20}
- 21function fail(msg) {
- 22 console.error(`calendar: ${msg}`);
- 23 process.exit(1);
- 24}
- 25const start = flag("--start", "");
- 26const weeks = Number(flag("--weeks", "4"));
- 27// Channels are separated by spaces or semicolons: "linkedin:mon,wed,fri newsletter:thu".
- 28const cadenceRaw = flag("--cadence", "linkedin:mon,wed,fri newsletter:thu blog:tue");
- 29const pillars = flag("--pillars", "Product tips,Customer stories,Behind the scenes")
- 30 .split(",")
- 31 .map((s) => s.trim())
- 32 .filter(Boolean);
- 33const skip = new Set(
- 34 flag("--skip", "")
- 35 .split(",")
- 36 .map((s) => s.trim())
- 37 .filter(Boolean),
- 38);
- 39const name = flag("--name", "content-calendar");
- 40const force = args.includes("--force");
- 41const [outDir = "content/calendar"] = args.filter((a) => a !== "--force");
- 43if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || Number.isNaN(Date.parse(start))) {
- 44 fail('--start is required, as YYYY-MM-DD (for example --start 2026-11-09)');
- 45}
- 46if (!Number.isInteger(weeks) || weeks < 1 || weeks > 26) fail("--weeks must be a whole number from 1 to 26");
- 47if (pillars.length === 0) fail("--pillars needs at least one pillar");
- 48if (!/^[a-z0-9-]+$/.test(name)) fail("--name may use lowercase letters, digits, and hyphens");
- 49for (const d of skip) if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) fail(`--skip dates must be YYYY-MM-DD (got "${d}")`);
- 51const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
- 52const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
- 53const FORMATS = {
- 54 linkedin: "post",
- 55 x: "thread",
- 56 twitter: "thread",
- 57 threads: "post",
- 58 bluesky: "post",
- 59 instagram: "carousel",
- 60 tiktok: "short video",
- 61 youtube: "video",
- 62 shorts: "short video",
- 63 newsletter: "issue",
- 64 blog: "article",
- 65 podcast: "episode",
- 66 webinar: "session",
- 67};
- 69const cadence = cadenceRaw
- 70 .split(/[;\s]+/)
- 71 .map((s) => s.trim())
- 72 .filter(Boolean)
- 73 .map((part) => {
- 74 const [channelRaw, daysRaw = ""] = part.split(":");
- 75 const channel = channelRaw.trim().toLowerCase();
- 76 if (!channel) fail(`--cadence part "${part}" has no channel`);
- 77 const days = daysRaw
- 78 .split(",")
- 79 .map((d) => d.trim().toLowerCase().slice(0, 3))
- 80 .filter(Boolean);
- 81 if (days.length === 0) fail(`--cadence "${part}" needs days, like linkedin:mon,wed`);
- 82 for (const d of days) if (!DAYS.includes(d)) fail(`--cadence "${part}": "${d}" is not a day (use mon, tue, ...)`);
- 83 return { channel, days: new Set(days.map((d) => DAYS.indexOf(d))) };
- 84 });
- 86// ---- slots -----------------------------------------------------------------
- 87const slots = [];
- 88const skipped = [];
- 89const startMs = Date.parse(`${start}T00:00:00Z`);
- 90for (let i = 0; i < weeks * 7; i++) {
- 91 const date = new Date(startMs + i * 86400000);
- 92 const iso = date.toISOString().slice(0, 10);
- 93 const dow = date.getUTCDay();
- 94 for (const c of cadence) {
- 95 if (!c.days.has(dow)) continue;
- 96 if (skip.has(iso)) {
- 97 skipped.push(`${iso} ${c.channel}`);
- 98 continue;
- 99 }
- 100 slots.push({ date: iso, day: DAY_NAMES[dow], week: Math.floor(i / 7) + 1, channel: c.channel });
- 101 }
- 102}
- 103if (slots.length === 0) fail("no slots: check --start, --weeks, --cadence, and --skip");
- 105// Rotate pillars per channel so each channel gets an even mix, offset by channel so
- 106// the same day does not lead with the same pillar everywhere.
- 107const counters = {};
- 108cadence.forEach((c, i) => (counters[c.channel] = i % pillars.length));
- 109for (const s of slots) {
- 110 s.pillar = pillars[counters[s.channel] % pillars.length];
- 111 counters[s.channel]++;
- 112 s.format = FORMATS[s.channel] ?? "post";
- 113}
- 115// ---- write -----------------------------------------------------------------
- 116const csvPath = join(outDir, `${name}.csv`);
- 117if (existsSync(csvPath) && !force) {
- 118 fail(`${csvPath} already exists and may have topics filled in. Use --name for a new file, or --force to replace it.`);
- 119}
- 120mkdirSync(outDir, { recursive: true });
- 121const COLS = ["date", "day", "week", "channel", "format", "pillar", "topic", "hook", "status", "owner", "link"];
- 122const cell = (v) => {
- 123 const s = String(v ?? "");
- 124 return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
- 125};
- 126const csv = [COLS.join(",")]
- 127 .concat(slots.map((s) => COLS.map((c) => cell(c === "status" ? "idea" : s[c] ?? "")).join(",")))
- 128 .join("\n");
- 129writeFileSync(csvPath, `${csv}\n`);
- 131const byChannel = {};
- 132const byPillar = {};
- 133for (const s of slots) {
- 134 byChannel[s.channel] = (byChannel[s.channel] ?? 0) + 1;
- 135 byPillar[s.pillar] = (byPillar[s.pillar] ?? 0) + 1;
- 136}
- 137const end = slots[slots.length - 1].date;
- 138const md = [`# Content calendar: ${start} to ${end}`, ""];
- 139md.push(
- 140 `${slots.length} pieces over ${weeks} week(s). ` +
- 141 Object.entries(byChannel)
- 142 .map(([k, v]) => `${k} ${v}`)
- 143 .join(", ") +
- 144 ".",
- 145);
- 146md.push(
- 147 `Pillars: ${Object.entries(byPillar)
- 148 .map(([k, v]) => `${k} ${v}`)
- 149 .join(", ")}.`,
- 150);
- 151if (skipped.length) md.push(`Skipped: ${skipped.join("; ")}.`);
- 152for (let w = 1; w <= weeks; w++) {
- 153 const rows = slots.filter((s) => s.week === w);
- 154 if (rows.length === 0) continue;
- 155 md.push("", `## Week ${w} (from ${rows[0].date})`, "", "| Date | Channel | Format | Pillar | Topic | Status |", "| --- | --- | --- | --- | --- | --- |");
- 156 for (const s of rows) md.push(`| ${s.day} ${s.date} | ${s.channel} | ${s.format} | ${s.pillar} | | idea |`);
- 157}
- 158md.push("");
- 159writeFileSync(join(outDir, `${name}.md`), md.join("\n"));
- 160writeFileSync(
- 161 join(outDir, `${name}.json`),
- 162 `${JSON.stringify({ start, end, weeks, slots: slots.length, by_channel: byChannel, by_pillar: byPillar, skipped }, null, 2)}\n`,
- 163);
- 164console.log(md.join("\n"));
- 165console.log(`Wrote ${csvPath}, ${join(outDir, `${name}.md`)}, ${join(outDir, `${name}.json`)}`);
