diff --git a/ventures/havre/tools/pages.mjs b/ventures/havre/tools/pages.mjs new file mode 100644 index 0000000..bac4e58 --- /dev/null +++ b/ventures/havre/tools/pages.mjs @@ -0,0 +1,149 @@ +// tools/pages.mjs: a page and a link for each of the harbor's own, as static files, from the cast/*.havre.json cards. +// tools/build.sh runs it right after tools/cast.mjs, every build: node tools/pages.mjs +// +// c/index.html the list: THE HARBOR'S OWN +// c//index.html one character: who they are, a few lines of them talking, and a way in +// c//.card.json the same character as a Character Card V3 (toCharacterCardV3, card.js) +// sitemap.txt the site's addresses: /, /c/ and each page +// +// The pages are the site's own style (style.css as it is), say what a visitor needs and nothing else, and run no +// script. A character for grown-ups gets no page: adult characters stay in the Studio (OWNER.md, decision 4). +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { SITE, creditLine, fill, normalizeCard, toCharacterCardV3, validateCard } from "../card.js"; + +const ROOT = fileURLToPath(new URL("../", import.meta.url)); +const castDir = join(ROOT, "cast"); +const OG_IMAGE = SITE + "shots/og.png"; +const EXAMPLES = 3; + +const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +/** the harbor's own, as normalized cards, by file name; a card that isn't fit to host stops the build */ +export function readCast() { + const files = readdirSync(castDir).filter((f) => f.endsWith(".havre.json")).sort(); + return files.map((f) => { + const card = normalizeCard(JSON.parse(readFileSync(join(castDir, f), "utf8"))); + const problems = validateCard(card); + if (problems.length) throw new Error(`${f}: ${problems.join("; ")}`); + if (f !== card.id + ".havre.json") throw new Error(`${f}: the file is named after the id (${card.id})`); + if (card.adult) throw new Error(`${f}: adult characters stay in the Studio; they get no hosted page`); + return card; + }); +} + +const MAST = `
+

HAVRE

+

a harbor for characters.
talk to venice characters on your own key.
no account. no server. nothing kept but what you keep.

+ +
`; + +const FOOT = `
havre.yolopreneur.lol / a venture on yolopreneur.lol / made by claude, october 2026 / not affiliated with venice.ai
`; + +/** the page around a body: title, description, canonical and Open Graph tags; no script, and a policy that allows none */ +function shell({ title, description, path, body }) { + const url = SITE + path; + return ` + + + + + + +${esc(title)} + + + + + + + + + + + + + + + + +${MAST} + +
+${body} +
+ +${FOOT} + + +`; +} + +/** one line of the script, set like the chat's own */ +const said = (who, text) => `
${esc(who)}${esc(text)}
`; + +/** a character's page */ +export function cardPage(card) { + const hello = (t) => fill(t, card, "you"); + const talk = [said(card.name, hello(card.greeting))]; + for (const e of card.examples.slice(0, EXAMPLES)) { + if (e.user) talk.push(said("you", hello(e.user))); + talk.push(said(card.name, hello(e.character))); + } + const credit = card.lineage.map(creditLine); + const title = `${card.name} in Havre`; + const description = `${card.name}${card.tagline ? ": " + card.tagline : "."} Meet ${card.name} in Havre, a harbor for characters: in your browser, no account.` + .replace(/\s+/g, " "); + const id = card.id; + const body = `

${esc(card.name)}

${card.tagline ? `

${esc(card.tagline)}

` : ""}
+
+${talk.join("\n")} +
${credit.length ? `\n

${esc(credit.join(" / "))}

` : ""} +

MEET ${esc(card.name.toUpperCase())} IN HAVRE

+

no key needed to say hello: ${esc(card.name)} answers from their own examples. with your own Venice key they really talk. nothing leaves your browser but the call to api.venice.ai.

+

the card: ${esc(id)}.havre.json / the same character as a Character Card V3, for SillyTavern-style apps: ${esc(id)}.card.json

+

← the harbor's own

`; + return shell({ title, description, path: `c/${id}/`, body }); +} + +/** the list of them all */ +export function indexPage(cards) { + const items = cards.map((c) => `
  • ${esc(c.name)}${c.tagline ? ": " + esc(c.tagline) : ""} / meet ${esc(c.name)} in Havre
  • `); + const body = `

    THE HARBOR'S OWN

    ${cards.length} characters who live in Havre. a page and a link for each.

    +
    +
      +${items.join("\n")} +
    +
    +

    each page has the character's card to keep, and the same character as a Character Card V3.

    `; + return shell({ + title: "THE HARBOR'S OWN | Havre", + description: `${cards.map((c) => c.name).join(", ")}: the characters who live in Havre, a harbor for characters. A page and a link for each.`, + path: "c/", body, + }); +} + +/** the site's addresses, one to a line */ +export function sitemap(cards) { + return [SITE, SITE + "c/", ...cards.map((c) => `${SITE}c/${c.id}/`)].join("\n") + "\n"; +} + +/** write c/ and sitemap.txt under `root` (the site, by default); what c/ held from an earlier build goes first */ +export function build(root = ROOT) { + const cards = readCast(), c = join(root, "c"); + rmSync(c, { recursive: true, force: true }); + const put = (file, text) => { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text); }; + put(join(c, "index.html"), indexPage(cards)); + for (const card of cards) { + put(join(c, card.id, "index.html"), cardPage(card)); + put(join(c, card.id, `${card.id}.card.json`), JSON.stringify(toCharacterCardV3(card), null, 1) + "\n"); + } + put(join(root, "sitemap.txt"), sitemap(cards)); + return cards; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const cards = build(); + console.log(`c/: ${cards.map((c) => c.name).join(", ")}, and the list`); +}