Files
mintel.me/apps/video/scripts/export.ts
T
mmintelandClaude Opus 5.5 5cbcb2eac4 feat: content strategy, image posts and a posting plan for the films
Adds twelve image posts made the same way as the films: six posters
(one picture of each film with its idea in two lines) and six cards
that answer a question clients ask before the first call. The answers
come from the questions and principles on the website.

The posting plan is data (six weeks, film, card and poster per week),
and the export writes the library in that order, by language, week and
day, with the text to post. All video posts now open with the hook of
their film. The reasoning is in plans/content-strategy.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:00:28 +02:00

206 lines
10 KiB
TypeScript

/**
* 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/<language>/week-<n>/<slot>-<day>-<id>/ 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<Record<string, string>> = {
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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"
? `<video src="${src}" controls loop muted playsinline preload="metadata"></video>`
: `<img src="${src}" alt="" loading="lazy">`;
return `<figure class="${format}">${element}<figcaption>${FORMAT_NOTE[format]} · <a href="${src}" download>Download</a></figcaption></figure>`;
})
.join("\n");
return shown || "<p>Not rendered yet.</p>";
};
const card = (post: PlannedPost, day: string, lang: Lang): string => `<article lang="${lang}" data-lang="${lang}">
<header><span class="tag">${day} · ${post.kind}</span><h3>${escapeHtml(title(post.kind, post.id, lang))}</h3></header>
<div class="media">${media(post, lang)}</div>
<div class="post">
<div class="post-head"><span class="tag">Post text</span><button type="button" class="copy">Copy</button></div>
<textarea readonly rows="12">${escapeHtml(postText(post.kind, post.id, lang))}</textarea>
</div>
</article>`;
const weeks = PLAN.map(
(week, index) => `<section>
<h2><span class="tag">Week ${index + 1}</span>${escapeHtml(week.topic)}</h2>
${week.posts.flatMap((post, slot) => LANGS.map((lang) => card(post, POSTING_DAYS[slot] ?? "", lang))).join("\n")}
</section>`,
).join("\n");
const page = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Content library</title>
<style>
:root { --ink: #0f172a; --muted: #64748b; --line: #e2e8f0; --wash: #f8fafc; }
* { box-sizing: border-box; }
body { margin: 0; background: #fff; color: var(--ink); font-family: Inter, system-ui, sans-serif; }
main { max-width: 1400px; margin: 0 auto; padding: 48px 20px 96px; }
h1 { font-size: clamp(40px, 7vw, 88px); font-weight: 900; letter-spacing: -0.05em; line-height: 0.95; margin: 0 0 12px; }
.intro { color: var(--muted); font-size: 18px; max-width: 62ch; margin: 0 0 24px; }
.switch { display: flex; gap: 8px; margin-bottom: 40px; }
section { border-top: 2px solid var(--ink); padding-top: 20px; margin-top: 56px; }
h2 { font-size: 34px; letter-spacing: -0.03em; margin: 0 0 8px; display: flex; flex-direction: column; gap: 8px; }
h3 { font-size: 22px; letter-spacing: -0.02em; margin: 0; }
article { border-top: 1px solid var(--line); padding: 28px 0; display: grid; gap: 20px; }
@media (min-width: 900px) { article { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); } article header { grid-column: 1 / -1; } }
body[data-show="de"] article[data-lang="en"], body[data-show="en"] article[data-lang="de"] { display: none; }
header { display: flex; flex-direction: column; gap: 6px; }
.tag, figcaption { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
.media { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
figure { margin: 0; }
video, img { display: block; width: 100%; border-radius: 16px; background: var(--ink); }
figure.feed, figure.square { width: min(100%, 300px); }
figure.story { width: min(100%, 230px); }
figure.wide { width: min(100%, 460px); }
figcaption { margin-top: 10px; }
a { color: var(--ink); }
.post-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
textarea { width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 16px; font: 15px/1.5 Inter, system-ui, sans-serif; color: var(--ink); background: var(--wash); resize: vertical; }
button { border: 1px solid var(--ink); border-radius: 999px; background: var(--ink); color: #fff; padding: 10px 20px; font: 700 11px Inter, system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
button[aria-pressed="false"] { background: #fff; color: var(--ink); }
</style>
</head>
<body data-show="de">
<main>
<h1>Content library</h1>
<p class="intro">Six weeks, three posts each: the film on Tuesday, a card on Thursday, the poster on Saturday. The plan is in <code>src/content/plan.ts</code>, the reasoning in <code>apps/web/plans/content-strategy.md</code>.</p>
<div class="switch">
<button type="button" data-set="de" aria-pressed="true">Deutsch</button>
<button type="button" data-set="en" aria-pressed="false">English</button>
</div>
${weeks}
</main>
<script>
for (const button of document.querySelectorAll("[data-set]")) {
button.addEventListener("click", () => {
document.body.dataset.show = button.dataset.set;
for (const other of document.querySelectorAll("[data-set]")) other.setAttribute("aria-pressed", String(other === button));
});
}
for (const button of document.querySelectorAll(".copy")) {
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(button.closest(".post").querySelector("textarea").value);
button.textContent = "Copied";
setTimeout(() => { button.textContent = "Copy"; }, 1500);
});
}
</script>
</body>
</html>
`;
mkdirSync(LIBRARY, { recursive: true });
writeFileSync(join(LIBRARY, "index.html"), page);
console.log(`Overview: ${join(LIBRARY, "index.html")}`);