/** * Renders the content library: every film and every image post in every language, with the text * to post next to it, and an overview page in the order of the posting plan. * * node scripts/export.ts everything * node scripts/export.ts --images only the image posts * node scripts/export.ts --videos only the films * node scripts/export.ts --only WebsiteDone one film or one image * node scripts/export.ts --formats feed one video format (feed, wide, square, story) * node scripts/export.ts --page only rewrite post texts and the overview page * * Output, in the order of the posting plan: * out/library/index.html * out/library//week-/--/ the files to upload and post.txt * Anything else in out/ is scratch: put checks and logs into out/tmp, which can be deleted at any time. * Run one export at a time, and redirect its log with `>|`, not `>`. */ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { IMAGES, IMAGE_IDS, type ImageId } from "../src/content/images.ts"; import { PLAN, POSTING_DAYS, type PlannedPost } from "../src/content/plan.ts"; import { COPY, LANGS, VIDEO_IDS, type Lang, type VideoId } from "../src/content/videos.ts"; const LIBRARY = join("out", "library"); const DEFAULT_VIDEO_FORMATS = ["feed", "wide"]; const IMAGE_FORMATS = ["feed", "square"]; const FORMAT_NOTE: Readonly> = { feed: "4:5, LinkedIn feed", wide: "16:9, YouTube and Google Ads", square: "1:1", story: "9:16, vertical", }; const option = (name: string): string | undefined => { const index = process.argv.indexOf(`--${name}`); return index === -1 ? undefined : process.argv[index + 1]; }; const flag = (name: string): boolean => process.argv.includes(`--${name}`); const only = option("only"); const videoFormats = option("formats")?.split(",") ?? DEFAULT_VIDEO_FORMATS; const pageOnly = flag("page"); const wanted = (id: string): boolean => only === undefined || id === only; const videoIds = flag("images") ? [] : VIDEO_IDS.filter(wanted); const imageIds = flag("videos") ? [] : IMAGE_IDS.filter(wanted); if (videoIds.length + imageIds.length === 0) { throw new Error(`Nothing to export for "${only}". Known: ${[...VIDEO_IDS, ...IMAGE_IDS].join(", ")}`); } /** Where a post lives inside the library: by language, then week, then day, as it will be posted. */ const place = (id: string, lang: Lang): string => { for (const [weekIndex, week] of PLAN.entries()) { const slot = week.posts.findIndex((post) => post.id === id); if (slot !== -1) { return `${lang}/week-${weekIndex + 1}/${slot + 1}-${(POSTING_DAYS[slot] ?? "").toLowerCase()}-${id}`; } } throw new Error(`${id} is not in the posting plan (src/content/plan.ts)`); }; const folder = (id: string, lang: Lang): string => join(LIBRARY, place(id, lang)); const fileName = (id: string, lang: Lang, format: string, extension: string): string => `${id}-${lang}-${format}.${extension}`; /** Runs a Remotion command and makes sure it really wrote the file. */ const render = (command: "render" | "still", composition: string, target: string, frame = 0): void => { console.log(`Rendering ${target}`); const startedAt = Date.now(); const extra = command === "still" ? [`--frame=${frame}`] : []; execFileSync("pnpm", ["exec", "remotion", command, "src/index.ts", composition, target, "--log=error", ...extra], { stdio: "inherit", }); // A render can end without an error and without a new file (seen with two renders started at once). if (!existsSync(target) || statSync(target).mtimeMs < startedAt) { throw new Error(`${target} was not written. Run one export at a time.`); } }; const postText = (kind: PlannedPost["kind"], id: string, lang: Lang): string => kind === "video" ? COPY[id as VideoId][lang].post : IMAGES[id as ImageId].copy[lang].post; const title = (kind: PlannedPost["kind"], id: string, lang: Lang): string => kind === "video" ? COPY[id as VideoId][lang].title : IMAGES[id as ImageId].copy[lang].title; for (const lang of LANGS) { for (const id of videoIds) { mkdirSync(folder(id, lang), { recursive: true }); writeFileSync(join(folder(id, lang), "post.txt"), `${postText("video", id, lang)}\n`); if (pageOnly) continue; for (const format of videoFormats) { render("render", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "mp4"))); } } for (const id of imageIds) { mkdirSync(folder(id, lang), { recursive: true }); writeFileSync(join(folder(id, lang), "post.txt"), `${postText("image", id, lang)}\n`); if (pageOnly) continue; for (const format of IMAGE_FORMATS) { render("still", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "png")), IMAGES[id].poster?.frame); } } } const escapeHtml = (text: string): string => text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); const media = (post: PlannedPost, lang: Lang): string => { const extension = post.kind === "video" ? "mp4" : "png"; const shown = Object.keys(FORMAT_NOTE) .filter((format) => existsSync(join(folder(post.id, lang), fileName(post.id, lang, format, extension)))) .map((format) => { const src = `${place(post.id, lang)}/${fileName(post.id, lang, format, extension)}`; const element = post.kind === "video" ? `` : ``; return `
${element}
${FORMAT_NOTE[format]} · Download
`; }) .join("\n"); return shown || "

Not rendered yet.

"; }; const card = (post: PlannedPost, day: string, lang: Lang): string => `
${day} · ${post.kind}

${escapeHtml(title(post.kind, post.id, lang))}

${media(post, lang)}
Post text
`; const weeks = PLAN.map( (week, index) => `

Week ${index + 1}${escapeHtml(week.topic)}

${week.posts.flatMap((post, slot) => LANGS.map((lang) => card(post, POSTING_DAYS[slot] ?? "", lang))).join("\n")}
`, ).join("\n"); const page = ` Content library

Content library

Six weeks, three posts each: the film on Tuesday, a card on Thursday, the poster on Saturday. The plan is in src/content/plan.ts, the reasoning in apps/web/plans/content-strategy.md.

${weeks}
`; mkdirSync(LIBRARY, { recursive: true }); writeFileSync(join(LIBRARY, "index.html"), page); console.log(`Overview: ${join(LIBRARY, "index.html")}`);