patx/afterdarklabs

import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

const projectDir = process.cwd();
const chrome = process.env.CHROME_BIN || "/usr/bin/google-chrome";
const messages = readFileSync(resolve(projectDir, "ads.txt"), "utf8")
  .split(/\r?\n/)
  .map((line) => line.trim())
  .filter(Boolean);

if (messages.length !== 35) {
  throw new Error(`Expected 35 messages in ads.txt, found ${messages.length}.`);
}

const escapeHtml = (value) => value
  .replaceAll("&", "&")
  .replaceAll("<", "&lt;")
  .replaceAll(">", "&gt;")
  .replaceAll('"', "&quot;")
  .replaceAll("'", "&#039;");

function formatCopy(message) {
  const separator = message.indexOf("? ");

  if (separator === -1) {
    return { className: "copy", html: escapeHtml(message) };
  }

  const question = message.slice(0, separator + 1);
  const answer = message.slice(separator + 2);

  // Short Q&A concepts read as two distinct beats. Longer questions retain
  // natural wrapping so the type never has to become smaller than the brand.
  if (Math.max(question.length, answer.length) > 26) {
    return { className: "copy", html: escapeHtml(message) };
  }

  return {
    className: "copy qa",
    html: `<span>${escapeHtml(question)}</span><span>${escapeHtml(answer)}</span>`,
  };
}

function buildHtml(message, number) {
  const copy = formatCopy(message);

  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>After Dark Labs Ad ${number}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;700&display=swap" rel="stylesheet">
<style>
html {
  background: #000;
}

body {
  position: relative;
  width: 100%;
  max-width: 1000px;
  aspect-ratio: 1;
  margin: 0 auto;
  padding: 36px;
  box-sizing: border-box;
  overflow: hidden;
  background: #000;
  color: #fff;
  font-family: "Ubuntu", sans-serif;
}

.copy {
  max-width: 400px;
  margin: 0;
  font-size: 64px;
  font-weight: 400;
  line-height: 1.15;
  text-align: left;
}

.copy.qa {
  max-width: calc(100% - 72px);
}

.copy.qa span {
  display: block;
  white-space: nowrap;
}

.brand {
  position: absolute;
  right: 40px;
  bottom: 18px;
  margin: 0;
  font-size: 40px;
  font-weight: 700;
  line-height: 1;
  text-align: right;
}
</style>
</head>
<body>
<h1 class="brand">AfterDarkLabs.io</h1>
<p class="${copy.className}">${copy.html}</p>
<script>
const copy = document.querySelector(".copy");
let fontSize = 64;

function copyFits() {
  if (copy.classList.contains("qa")) {
    const availableWidth = document.body.clientWidth - 72;
    return [...copy.children].every((line) => line.scrollWidth <= availableWidth);
  }

  return copy.getBoundingClientRect().bottom <= 625;
}

function fitCopy() {
  while (!copyFits() && fontSize > 44) {
    fontSize -= 1;
    copy.style.fontSize = fontSize + "px";
  }
}

fitCopy();
document.fonts.ready.then(fitCopy);
</script>
</body>
</html>
`;
}

for (const [index, message] of messages.entries()) {
  const number = index + 2;
  const htmlPath = resolve(projectDir, `ad${number}.html`);
  const pngPath = resolve(projectDir, `ad${number}.png`);

  writeFileSync(htmlPath, buildHtml(message, number));

  const result = spawnSync(chrome, [
    "--headless=new",
    "--disable-gpu",
    "--hide-scrollbars",
    "--force-device-scale-factor=1",
    "--run-all-compositor-stages-before-draw",
    "--virtual-time-budget=1000",
    `--screenshot=${pngPath}`,
    "--window-size=723,754",
    pathToFileURL(htmlPath).href,
  ], { encoding: "utf8" });

  if (result.status !== 0) {
    throw new Error(`Chrome failed while rendering ad${number}.png:\n${result.stderr}`);
  }

  process.stdout.write(`Created ad${number}.html and ad${number}.png\n`);
}