Files
forward-future__loopy/scripts/build-loop-pages.mjs
2026-06-18 17:56:13 -07:00

455 lines
15 KiB
JavaScript

import { mkdir, rm, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { loops, site } from "./loop-data.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outputRoot = path.join(root, "site");
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeXml(value) {
return escapeHtml(value);
}
function formatDate(value) {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${value}T00:00:00Z`));
}
function absoluteUrl(slug = "") {
return `${site.baseUrl}${slug ? `loops/${slug}/` : ""}`;
}
function socialImageUrl(loop) {
return `${site.baseUrl}assets/social/${loop.slug}-${site.socialImageVersion}.${site.socialImageExtension}`;
}
function relatedLinks(loop) {
return loop.related
.map((slug) => loops.find((candidate) => candidate.slug === slug))
.filter(Boolean)
.map(
(related) => `
<a class="related-loop-link" href="../${escapeHtml(related.slug)}/">
${escapeHtml(related.title)}
</a>`,
)
.join("");
}
function hereNowCredit(assetPath, modifier) {
return `<a
class="here-now-credit here-now-credit--${modifier}"
data-here-now-credit
href="https://here.now/r/signals"
target="_blank"
rel="noopener noreferrer"
aria-label="Hosted by here.now"
>
<img
class="here-now-credit__icon"
src="${assetPath}"
alt=""
aria-hidden="true"
/>
<span class="here-now-credit__text">
<small>Hosted by</small>
<strong>here.now</strong>
</span>
</a>`;
}
function structuredData(loop) {
const url = absoluteUrl(loop.slug);
const imageUrl = socialImageUrl(loop);
const [authorName, authorAffiliation] = loop.author.split(" / ");
const author = {
"@type": "Person",
name: authorName,
};
if (authorAffiliation) {
author.affiliation = {
"@id": `${site.baseUrl}#organization`,
};
}
return JSON.stringify(
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "BreadcrumbList",
"@id": `${url}#breadcrumbs`,
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: site.name,
item: site.baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: loop.title,
item: url,
},
],
},
{
"@type": "Article",
"@id": `${url}#article`,
headline: loop.title,
description: loop.description,
url,
mainEntityOfPage: url,
datePublished: loop.published,
dateModified: loop.modified,
articleSection: loop.categoryLabel,
keywords: loop.keywords,
...(loop.sourceUrl ? { isBasedOn: loop.sourceUrl } : {}),
image: {
"@type": "ImageObject",
url: imageUrl,
width: 1200,
height: 630,
},
author,
publisher: {
"@id": `${site.baseUrl}#organization`,
},
isPartOf: {
"@id": `${site.baseUrl}#collection`,
},
},
{
"@type": "Organization",
"@id": `${site.baseUrl}#organization`,
name: site.publisher,
url: "https://forwardfuture.ai/",
logo: {
"@type": "ImageObject",
url: `${site.baseUrl}assets/ff-mark.png`,
width: 1920,
height: 1920,
},
},
],
},
null,
2,
).replaceAll("</script", "<\\/script");
}
function renderLoopPage(loop) {
const url = absoluteUrl(loop.slug);
const imageUrl = socialImageUrl(loop);
const imageAlt = `${loop.title} — Forward Future Loop Library`;
const steps = loop.steps
.map((step) => ` <li>${escapeHtml(step)}</li>`)
.join("\n");
return `<!doctype html>
<!-- Generated by scripts/build-loop-pages.mjs. -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="${escapeHtml(loop.description)}" />
<meta name="author" content="${escapeHtml(loop.author)}" />
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1" />
<meta name="theme-color" content="#faf8f7" />
<meta name="color-scheme" content="light dark" />
<script>
(() => {
const storageKey = "loop-library-theme";
let storedTheme;
try {
storedTheme = window.localStorage.getItem(storageKey);
} catch {
storedTheme = null;
}
const theme =
storedTheme === "light" || storedTheme === "dark"
? storedTheme
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.dataset.theme = theme;
document
.querySelector('meta[name="theme-color"]')
.setAttribute("content", theme === "dark" ? "#101010" : "#faf8f7");
})();
</script>
<meta property="og:type" content="article" />
<meta property="og:site_name" content="${escapeHtml(site.name)}" />
<meta property="og:title" content="${escapeHtml(loop.seoTitle)}" />
<meta property="og:description" content="${escapeHtml(loop.description)}" />
<meta property="og:url" content="${escapeHtml(url)}" />
<meta property="og:image" content="${escapeHtml(imageUrl)}" />
<meta property="og:image:secure_url" content="${escapeHtml(imageUrl)}" />
<meta property="og:image:type" content="${escapeHtml(site.socialImageMimeType)}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="${escapeHtml(imageAlt)}" />
<meta property="article:published_time" content="${escapeHtml(loop.published)}" />
<meta property="article:modified_time" content="${escapeHtml(loop.modified)}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeHtml(loop.seoTitle)}" />
<meta name="twitter:description" content="${escapeHtml(loop.description)}" />
<meta name="twitter:image" content="${escapeHtml(imageUrl)}" />
<meta name="twitter:image:alt" content="${escapeHtml(imageAlt)}" />
<link rel="canonical" href="${escapeHtml(url)}" />
<link rel="sitemap" type="application/xml" href="${escapeHtml(site.baseUrl)}sitemap.xml" />
<link rel="alternate" type="application/atom+xml" title="${escapeHtml(site.name)} updates" href="${escapeHtml(site.baseUrl)}feed.xml" />
<link rel="icon" type="image/png" href="../../assets/favicon.png" />
<link rel="stylesheet" href="../../styles.css?v=20260617-simple-detail" />
<script type="application/ld+json">
${structuredData(loop)}
</script>
<script src="../../script.js?v=20260618-x-handle" defer></script>
<title>${escapeHtml(loop.seoTitle)}</title>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<a class="brand-lockup" href="../../" aria-label="Forward Future Loop Library home">
<img
class="brand-mark"
src="../../assets/favicon.png"
width="32"
height="32"
alt=""
/>
<span class="brand-name">Forward Future</span>
<span class="brand-product">Loop Library</span>
</a>
<nav class="site-nav" aria-label="Primary navigation">
<a href="../../#library">All loops</a>
<a href="../../#tips">Best practices</a>
<button
class="theme-toggle"
id="theme-toggle"
type="button"
aria-label="Switch to dark mode"
aria-pressed="false"
>
<svg class="theme-icon theme-icon-light" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3.5"></circle>
<path d="M12 2v3M12 19v3M4.9 4.9 7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1"></path>
</svg>
<svg class="theme-icon theme-icon-dark" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 15.2A8.5 8.5 0 0 1 8.8 4a8.5 8.5 0 1 0 11.2 11.2Z"></path>
</svg>
<span class="theme-label theme-label-light">Light</span>
<span class="theme-label theme-label-dark">Dark</span>
</button>
<a class="nav-cta" href="../../#submit">Submit a loop</a>
${hereNowCredit("../../assets/here-now-icon.svg", "header")}
</nav>
</header>
<main class="detail-main page-width" id="main">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<a href="../../">&larr; All loops</a>
</nav>
<article class="loop-detail">
<header class="detail-hero">
<p class="eyebrow">Loop ${escapeHtml(loop.number)}</p>
<h1>${escapeHtml(loop.title)}</h1>
<p class="detail-lede">${escapeHtml(loop.description)}</p>
<p class="detail-byline">
Contributed by <strong>${escapeHtml(loop.author)}</strong>${
loop.sourceUrl
? ` · <a class="detail-source-link" href="${escapeHtml(loop.sourceUrl)}" target="_blank" rel="noopener noreferrer">Source</a>`
: ""
}
</p>
</header>
<div class="detail-stack">
<section class="detail-prompt-card" data-copy-root aria-labelledby="copy-loop">
<div class="detail-prompt-heading">
<div>
<p class="eyebrow">Ready-to-use prompt</p>
<h2 id="copy-loop">Copy the loop</h2>
</div>
<button class="copy-button" type="button">
<span>Copy</span>
</button>
</div>
<p data-prompt>${escapeHtml(loop.prompt)}</p>
</section>
<section class="verification-card" aria-labelledby="verify-stop">
<p class="eyebrow">Verify / stop</p>
<div>
<h2 id="verify-stop">${escapeHtml(loop.verifyTitle)}</h2>
<p>${escapeHtml(loop.verifyDetail)}</p>
</div>
</section>
<details class="detail-more">
<summary>
<span>Context and guidance</span>
<small>When to use it, steps, safety notes, and related loops</small>
</summary>
<div class="detail-more-body">
<dl class="detail-meta">
<div>
<dt>Published</dt>
<dd><time datetime="${escapeHtml(loop.published)}">${escapeHtml(formatDate(loop.published))}</time></dd>
</div>
<div>
<dt>Updated</dt>
<dd><time datetime="${escapeHtml(loop.modified)}">${escapeHtml(formatDate(loop.modified))}</time></dd>
</div>
</dl>
<section aria-labelledby="use-when">
<h2 id="use-when">Use this when</h2>
<p>${escapeHtml(loop.useWhen)}</p>
</section>
<section aria-labelledby="run-loop">
<h2 id="run-loop">How to run it</h2>
<ol class="detail-steps">
${steps}
</ol>
</section>
<section aria-labelledby="why-it-works">
<h2 id="why-it-works">Why it works</h2>
<p>${escapeHtml(loop.why)}</p>
</section>
<section class="implementation-note" aria-labelledby="implementation-note">
<h2 id="implementation-note">Implementation note</h2>
<p>${escapeHtml(loop.note)}</p>
</section>
<nav class="related-loops" aria-labelledby="related-heading">
<h2 id="related-heading">Related loops</h2>
<div>
${relatedLinks(loop)}
</div>
</nav>
</div>
</details>
</div>
</article>
</main>
<footer class="site-footer">
<div class="page-width footer-inner">
<p><strong>Forward Future</strong> <span>Make the future legible.</span></p>
<div class="footer-actions">
<p>
<a href="../../">Loop Library</a>
<a href="https://forwardfuture.ai/" rel="noopener">forwardfuture.ai</a>
<span>&copy; 2026</span>
</p>
${hereNowCredit("../../assets/here-now-icon.svg", "footer")}
</div>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</body>
</html>
`;
}
function renderSitemap() {
const entries = [
{
url: site.baseUrl,
modified: site.updated,
},
...loops.map((loop) => ({
url: absoluteUrl(loop.slug),
modified: loop.modified,
})),
];
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries
.map(
(entry) => ` <url>
<loc>${escapeXml(entry.url)}</loc>
<lastmod>${escapeXml(entry.modified)}</lastmod>
</url>`,
)
.join("\n")}
</urlset>
`;
}
function renderFeed() {
const updated = `${site.updated}T00:00:00-07:00`;
return `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${escapeXml(site.name)}</title>
<subtitle>${escapeXml(site.description)}</subtitle>
<id>${escapeXml(site.baseUrl)}</id>
<link href="${escapeXml(site.baseUrl)}" />
<link href="${escapeXml(site.baseUrl)}feed.xml" rel="self" type="application/atom+xml" />
<updated>${updated}</updated>
<author>
<name>${escapeXml(site.publisher)}</name>
<uri>https://forwardfuture.ai/</uri>
</author>
${loops
.map(
(loop) => ` <entry>
<title>${escapeXml(loop.title)}</title>
<id>${escapeXml(absoluteUrl(loop.slug))}</id>
<link href="${escapeXml(absoluteUrl(loop.slug))}" />
<published>${escapeXml(loop.published)}T00:00:00-07:00</published>
<updated>${escapeXml(loop.modified)}T00:00:00-07:00</updated>
<author>
<name>${escapeXml(loop.author)}</name>
</author>
<summary>${escapeXml(loop.description)}</summary>
</entry>`,
)
.join("\n")}
</feed>
`;
}
await rm(path.join(outputRoot, "loops"), { recursive: true, force: true });
for (const loop of loops) {
const directory = path.join(outputRoot, "loops", loop.slug);
await mkdir(directory, { recursive: true });
await writeFile(path.join(directory, "index.html"), renderLoopPage(loop));
}
await writeFile(path.join(outputRoot, "sitemap.xml"), renderSitemap());
await writeFile(path.join(outputRoot, "feed.xml"), renderFeed());
console.log(`Generated ${loops.length} loop pages, sitemap.xml, and feed.xml.`);