Skip to content
SlashClone

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.

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