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>
This commit is contained in:
+129
-61
@@ -1,21 +1,30 @@
|
||||
/**
|
||||
* Renders the video library: every video in every language, with the text to post next to it,
|
||||
* and an overview page to watch, copy and download from.
|
||||
* 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 --only WebsiteDone one video
|
||||
* node scripts/export.ts --formats feed one format (feed, wide, square, story)
|
||||
* 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: out/library/index.html and out/library/<video>/<language>/.
|
||||
* 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_FORMATS = ["feed", "wide"];
|
||||
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",
|
||||
@@ -27,32 +36,66 @@ 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 formats = option("formats")?.split(",") ?? DEFAULT_FORMATS;
|
||||
const pageOnly = process.argv.includes("--page");
|
||||
const videoIds = VIDEO_IDS.filter((id) => only === undefined || id === only);
|
||||
if (videoIds.length === 0) throw new Error(`Unknown video "${only}". Known: ${VIDEO_IDS.join(", ")}`);
|
||||
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(", ")}`);
|
||||
}
|
||||
|
||||
const fileName = (id: VideoId, lang: Lang, format: string): string => `${id}-${lang}-${format}.mp4`;
|
||||
const folder = (id: VideoId, lang: Lang): string => join(LIBRARY, id, lang);
|
||||
/** 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}`;
|
||||
|
||||
for (const id of videoIds) {
|
||||
for (const lang of LANGS) {
|
||||
/** 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"), `${COPY[id][lang].post}\n`);
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("video", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of formats) {
|
||||
const target = join(folder(id, lang), fileName(id, lang, format));
|
||||
console.log(`Rendering ${target}`);
|
||||
const startedAt = Date.now();
|
||||
execFileSync("pnpm", ["exec", "remotion", "render", "src/index.ts", `${id}-${lang}-${format}`, target, "--log=error"], {
|
||||
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.`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,70 +103,94 @@ for (const id of videoIds) {
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
const card = (id: VideoId, lang: Lang): string => {
|
||||
const copy = COPY[id][lang];
|
||||
const videos = Object.keys(FORMAT_NOTE)
|
||||
.filter((format) => existsSync(join(folder(id, lang), fileName(id, lang, format))))
|
||||
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 = `${id}/${lang}/${fileName(id, lang, format)}`;
|
||||
return `<figure class="${format}">
|
||||
<video src="${src}" controls loop muted playsinline preload="metadata"></video>
|
||||
<figcaption>${FORMAT_NOTE[format]} · <a href="${src}" download>Download</a></figcaption>
|
||||
</figure>`;
|
||||
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 `<article>
|
||||
<header><span class="lang">${lang.toUpperCase()}</span><h2>${escapeHtml(copy.title)}</h2></header>
|
||||
<div class="videos">${videos || "<p>Not rendered yet.</p>"}</div>
|
||||
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>Post text</span><button type="button">Copy</button></div>
|
||||
<textarea readonly rows="14">${escapeHtml(copy.post)}</textarea>
|
||||
<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>Video library</title>
|
||||
<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: 60ch; margin: 0 0 48px; }
|
||||
article { border-top: 1px solid var(--line); padding: 36px 0; }
|
||||
header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 20px; }
|
||||
h2 { font-size: 26px; letter-spacing: -0.02em; margin: 0; }
|
||||
.lang, figcaption, .post-head { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
|
||||
.videos { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; }
|
||||
.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 { display: block; width: 100%; border: 1px solid var(--line); border-radius: 16px; background: var(--wash); }
|
||||
figure.feed, figure.square { width: min(100%, 340px); }
|
||||
figure.story { width: min(100%, 260px); }
|
||||
figure.wide { width: min(100%, 620px); }
|
||||
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 { margin-top: 24px; max-width: 760px; }
|
||||
.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: 0; 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 { 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>
|
||||
<body data-show="de">
|
||||
<main>
|
||||
<h1>Video library</h1>
|
||||
<p class="intro">Every video in English and German, with the text to post alongside it. Rendered by <code>node scripts/export.ts</code>.</p>
|
||||
${VIDEO_IDS.flatMap((id) => LANGS.map((lang) => card(id, lang))).join("\n")}
|
||||
<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(".post button")) {
|
||||
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 () => {
|
||||
const text = button.closest(".post").querySelector("textarea").value;
|
||||
await navigator.clipboard.writeText(text);
|
||||
await navigator.clipboard.writeText(button.closest(".post").querySelector("textarea").value);
|
||||
button.textContent = "Copied";
|
||||
setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
||||
});
|
||||
@@ -133,5 +200,6 @@ const page = `<!doctype html>
|
||||
</html>
|
||||
`;
|
||||
|
||||
mkdirSync(LIBRARY, { recursive: true });
|
||||
writeFileSync(join(LIBRARY, "index.html"), page);
|
||||
console.log(`Overview: ${join(LIBRARY, "index.html")}`);
|
||||
|
||||
Reference in New Issue
Block a user