mirror of
https://github.com/Forward-Future/loopy.git
synced 2026-09-22 05:33:01 +08:00
455 lines
15 KiB
JavaScript
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("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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="../../">← 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>© 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.`);
|