diff --git a/config/commands.js b/config/commands.js index dfaef972..8051a9ca 100644 --- a/config/commands.js +++ b/config/commands.js @@ -168,11 +168,12 @@ const commands = { // Points at the static, crawlable mirror of this terminal generated by // scripts/build-pages.js. Same data, plain HTML, real URLs. www: function () { - term.stylePrint("Plain-text version of this site:"); - term.displayURL("https://root.vc/about/"); - term.displayURL("https://root.vc/portfolio/"); - term.displayURL("https://root.vc/team/"); - term.displayURL("https://root.vc/jobs/"); + // There used to be a static mirror at /about/, /portfolio/ and friends. + // Those URLs now redirect back into this terminal, so pointing at them + // would just send someone in a circle. llms.txt is the real plain-text + // version of everything here. + term.stylePrint("You're looking at it. Plain text, for machines:"); + term.displayURL("https://root.vc/llms.txt"); }, // ── File System ───────────────────────────────────────────────────────────── diff --git a/css/pages.css b/css/pages.css deleted file mode 100644 index 288ae2b8..00000000 --- a/css/pages.css +++ /dev/null @@ -1,227 +0,0 @@ -/* pages.css — Styles for the static mirror generated by scripts/build-pages.js. - * - * Deliberately NOT css/styles.css: that file sets `html, body { height: 100%; - * overflow: hidden }` for the fixed-viewport terminal shell, which would make - * these long documents unscrollable. - * - * Colors match what the terminal actually renders — #2c2c2c from styles.css, - * and xterm.js's default bright ANSI palette, which is what colorText() in - * js/terminal.js emits (1;35 magenta, 1;34 blue, 1;33 yellow, 1;32 green). - * - * The Source Code Pro + preconnect hints live in layout()'s in - * scripts/build-pages.js rather than an @import here: @import serializes the - * font request behind this stylesheet's own download and parse, where a - * preconnected can start immediately in parallel. - */ - -:root { - --bg: #2c2c2c; - --surface: #000; - --txt: #e5e5e5; - --txt-bright: #fff; - --muted: #9a9a9a; - --rule: #444; - --magenta: #d670d6; /* colorText "command" */ - --blue: #3b8eea; /* colorText "hyperlink" */ - --yellow: #f5f543; /* colorText "user" */ - --green: #23d18b; /* colorText "prompt" */ -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 24px 12px; - background: var(--bg); - color: var(--txt); - font-family: "Source Code Pro", ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 15px; - line-height: 1.6; - /* Long URLs in the fact lists must not force the page to scroll sideways. */ - overflow-wrap: break-word; -} - -.window { - max-width: 76ch; - margin: 0 auto; - padding: 20px 16px; - background: var(--surface); - border: 1px solid var(--rule); -} - -/* ── Nav ──────────────────────────────────────────────────────────────────── */ - -header { - padding-bottom: 12px; - border-bottom: 1px solid var(--rule); -} - -nav a, -nav [aria-current] { - color: var(--blue); - text-decoration: none; -} - -nav [aria-current] { - color: var(--yellow); - font-weight: 700; -} - -nav a:hover, -nav a:focus { - text-decoration: underline; -} - -nav .sep { - color: var(--muted); -} - -/* ── Prompt line ──────────────────────────────────────────────────────────── */ - -.prompt { - margin: 20px 0 0; - color: var(--muted); -} - -.prompt .user { - color: var(--green); - font-weight: 700; -} - -.prompt .path { - color: var(--blue); - font-weight: 700; -} - -.prompt .punct { - color: var(--txt); -} - -.prompt .cmd { - color: var(--magenta); - font-weight: 700; -} - -/* ── Type ─────────────────────────────────────────────────────────────────── */ - -h1 { - margin: 4px 0 16px; - font-size: 1.6rem; - line-height: 1.25; - color: var(--txt-bright); -} - -h2 { - margin: 32px 0 12px; - padding-top: 12px; - border-top: 1px solid var(--rule); - font-size: 1.05rem; - color: var(--yellow); -} - -p { - margin: 0 0 14px; -} - -.lede { - color: var(--txt-bright); -} - -.role { - color: var(--yellow); -} - -.desc { - color: var(--muted); -} - -a { - color: var(--blue); -} - -a:hover, -a:focus { - color: var(--txt-bright); -} - -code { - color: var(--magenta); - font-family: inherit; - font-weight: 700; -} - -/* ── Lists ────────────────────────────────────────────────────────────────── */ - -ul { - margin: 0 0 14px; - padding-left: 2ch; -} - -li { - margin-bottom: 6px; -} - -.listing { - list-style: none; - padding-left: 0; -} - -.listing > li { - margin-bottom: 12px; - padding-left: 2ch; - text-indent: -2ch; -} - -.listing > li::before { - content: "> "; - color: var(--green); -} - -.listing .role, -.listing .desc { - display: block; - padding-left: 2ch; - text-indent: 0; -} - -/* ── Definition lists ─────────────────────────────────────────────────────── */ - -.facts { - margin: 0 0 14px; -} - -.facts dt { - color: var(--green); -} - -.facts dd { - margin: 0 0 10px 2ch; -} - -/* ── Footer ───────────────────────────────────────────────────────────────── */ - -footer { - margin-top: 32px; - padding-top: 12px; - border-top: 1px solid var(--rule); - color: var(--muted); - font-size: 0.9rem; -} - -footer p { - margin: 0; -} - -/* ── Wide screens ─────────────────────────────────────────────────────────── */ - -@media (min-width: 720px) { - body { - padding: 40px 24px; - } - - .window { - padding: 32px 40px; - } -} diff --git a/js/terminal-ext.js b/js/terminal-ext.js index 2eb8a83f..bedb5379 100644 --- a/js/terminal-ext.js +++ b/js/terminal-ext.js @@ -36,8 +36,19 @@ const extend = (term) => { `${term.user}${term.sep}${term.host} ${term.cwd} $`; // Deep link: parse the URL hash as a command to run on load. - // E.g. /#whois-avidan → runs `whois avidan` (hyphens become spaces). - term.deepLink = window.location.hash.replace("#", "").split("-").join(" "); + // E.g. /#whois-avidan → runs `whois avidan`. + // + // Only the FIRST hyphen separates the command from its argument. Splitting on + // every hyphen would turn a hyphenated slug into two arguments, so a company + // added to config/portfolio.js as `vibe-robotics` would break its own deep + // link. No slug has a hyphen today, but fragments are now the only way to + // address a specific company or person, so this has to hold for any slug. + term.deepLink = (() => { + const raw = window.location.hash.replace(/^#/, ""); + const split = raw.indexOf("-"); + if (split === -1) return raw; + return `${raw.slice(0, split)} ${raw.slice(split + 1)}`; + })(); // ── Prompt ───────────────────────────────────────────────────────────────── @@ -403,7 +414,10 @@ const extend = (term) => { addToHistory: false, promptAfter: false, showLeadingNewline: false, - trackAnalytics: false, + // Deep links are how visitors now reach a specific company or person, + // so these are the arrivals worth counting. Fragments never fire a + // pageview of their own, so without this they would be invisible. + trackAnalytics: true, }).catch((error) => { console.error("Deep link failed", error); }); diff --git a/scripts/build-assets.js b/scripts/build-assets.js index 643b10f0..783788a2 100644 --- a/scripts/build-assets.js +++ b/scripts/build-assets.js @@ -53,7 +53,6 @@ const staticAssets = [ "favicon.png", "welcome.htm", "css/bootstrap.css", - "css/pages.css", "css/styles.css", "images", "videos", diff --git a/scripts/build-pages.js b/scripts/build-pages.js index 11fe304a..8e62fd0f 100644 --- a/scripts/build-pages.js +++ b/scripts/build-pages.js @@ -1,21 +1,37 @@ -// build-pages.js — Generates the static, crawlable mirror of the terminal. +// build-pages.js — Makes the terminal legible to crawlers, without a website. // // root.vc is an xterm.js terminal: everything it knows lives in config/*.js and // is only written to the DOM when a visitor types a command. Search engines and -// LLM crawlers therefore see almost nothing. This script renders that same data -// as plain HTML at real URLs so they can read it. +// LLM crawlers therefore see almost nothing. This script closes that gap from a +// single URL rather than by shipping a parallel website. // -// /portfolio/ /portfolio// /team/ /team// /about/ /jobs/ -// robots.txt sitemap.xml llms.txt llms-full.txt +// There used to be a static mirror here — real HTML pages at /portfolio/, +// /team/, /about/ and /jobs/. It worked, and that was the problem: Google +// indexed all 70-odd of them and started showing sitelinks to About / Team / +// Jobs under the root.vc result, which advertises a conventional site sitting +// behind the terminal. The terminal is the product, so the mirror is gone. +// +// What replaces it: +// +// index.html an offscreen block naming every company and person, so a +// crawler that never runs JS still reads the whole map +// _redirects every old mirror URL 301s into the terminal at the command +// that shows the same thing +// llms.txt the same content as prose, for LLM crawlers +// sitemap.xml one URL, because there is now genuinely one page +// +// Content is addressed by URL fragment: /#tldr-chargelab tells the terminal to +// run `tldr chargelab` on load. Fragments never reach the server and Google +// strips them before indexing, so this deliberately trades per-company search +// results for having exactly one indexed URL. // // config/*.js is the ONLY source of truth. Nothing here is hand-maintained: -// editing a description regenerates its page, both indexes, the sitemap, the -// llms files, and the crawlable index block in index.html. +// editing a description regenerates the homepage block, the llms files, and the +// redirects. // // Everything is written into dist/, which is the Netlify publish directory and // is gitignored. Generated files are never committed, so there is nothing that -// can fall out of sync with config/*.js and nothing to check for drift — the -// mirror is rebuilt from scratch on every deploy. +// can fall out of sync with config/*.js and nothing to check for drift. const fs = require("fs"); const path = require("path"); @@ -24,7 +40,6 @@ const rootDir = path.resolve(__dirname, ".."); const outDir = path.join(rootDir, "dist"); const ORIGIN = "https://root.vc"; -const OG_IMAGE = `${ORIGIN}/images/og-image.png`; // Sentinel comment pairs in index.html; everything between each pair is // regenerated from config/*.js on every build. @@ -90,20 +105,6 @@ function absUrl(pathname) { return `${ORIGIN}${pathname}`; } -// Meta descriptions get truncated by search engines past ~160 characters, so -// cut on a word boundary rather than letting them do it mid-word. The word -// boundary is only honored if it doesn't throw away more than a quarter of the -// budget — otherwise a description whose first word is long would fall back to -// a mid-word cut anyway, which is still better than returning almost nothing. -function metaDescription(text, limit = 160) { - const clean = String(text).replace(/\s+/g, " ").trim(); - if (clean.length <= limit) return clean; - const cut = clean.slice(0, limit - 1); - const lastSpace = cut.lastIndexOf(" "); - const minBoundary = Math.floor(limit * 0.25); - return `${(lastSpace > minBoundary ? cut.slice(0, lastSpace) : cut).replace(/[.,;:]$/, "")}…`; -} - // Portfolio entries can carry "(inactive)" instead of a real URL (see // js/geo.js). Also rejects anything that isn't a plain http(s) URL — e.g. a // stray "javascript:" or "data:" scheme in config data — so it can never reach @@ -119,100 +120,25 @@ function hasLink(url) { } } -// ── Layout ──────────────────────────────────────────────────────────────────── - -const NAV = [ - ["/", "terminal"], - ["/portfolio/", "portfolio"], - ["/team/", "team"], - ["/about/", "about"], - ["/jobs/", "jobs"], -]; - -function renderNav(currentPath) { - // The whitespace around the separator is load-bearing: without it the whole - // nav is one unbreakable run and overflows narrow viewports. - const links = NAV.map(([href, label]) => - href === currentPath - ? `${esc(label)}` - : `${esc(label)}` - ).join(' '); - return ``; -} +// ── Deep links ──────────────────────────────────────────────────────────────── -// A prompt line echoing the terminal command that shows the same content. -// Decorative, so it is hidden from assistive tech. -function renderPrompt(command) { - return ( - `` - ); +// js/terminal-ext.js reads the URL fragment as a command to run on load, +// splitting the command from its argument on the FIRST hyphen. Every link, +// redirect and llms.txt entry below is built from this one function so the two +// halves cannot drift — if the separator ever changes, it changes here. +function deepLink(command) { + return `/#${command.replace(/\s+/g, "-")}`; } -// Every mirror page is emitted noindex. root.vc should be the only result -// Google shows: the terminal is the front door, and sitelinks to /about/ and -// /team/ give the trick away. noindex is a search-INDEXING directive, not a -// fetch permission — crawlers may still read the page, so GPTBot, ClaudeBot and -// PerplexityBot keep getting the full content. That is the whole reason this is -// not a robots.txt Disallow, which would hide the mirror from them too. -// "follow" keeps link equity flowing back to the homepage. -function layout({ title, description, pathname, command, body, graph }) { - const canonical = absUrl(pathname); - const jsonLd = jsonLdScript({ "@context": "https://schema.org", "@graph": graph }); - - return ` - - - - - ${esc(title)} - - - - - - - - - - - - - - - - - - - - - - - ${jsonLd} - - -
-
- ${renderNav(pathname)} -
-
-${renderPrompt(command)} -${body} -
-
-

This is the plain-text version of root.vc, which is a terminal. Everything here is also available by typing ${esc(command)} there.

-
-
- - -`; -} +// The terminal command that displays each thing the old mirror had a page for. +const COMMANDS = { + about: "whois root", + jobs: "jobs", + portfolioIndex: "tldr", + teamIndex: "whois", + company: (slug) => `tldr ${slug}`, + person: (slug) => `whois ${slug}`, +}; // ── Shared schema.org nodes ─────────────────────────────────────────────────── @@ -233,456 +159,43 @@ function organizationNode(firm, team) { sameAs: firm.social, knowsAbout: firm.focusAreas, employee: Object.keys(team).map((slug) => ({ - "@id": `${ORIGIN}/team/${slug}/#person`, + "@id": `${ORIGIN}/#person-${slug}`, })), }; } -function breadcrumbNode(trail) { +// People and companies used to be the mainEntity of their own page. With one +// page left they become nodes in the homepage graph, keyed by fragment @ids so +// each is still individually addressable to a parser. +function personNode(slug, person) { return { - "@type": "BreadcrumbList", - itemListElement: trail.map(([name, pathname], index) => ({ - "@type": "ListItem", - position: index + 1, - name, - item: absUrl(pathname), - })), + "@type": "Person", + "@id": `${ORIGIN}/#person-${slug}`, + name: person.name, + jobTitle: person.title, + description: person.description, + url: absUrl(deepLink(COMMANDS.person(slug))), + image: `${ORIGIN}/images/${slug}.png`, + email: `${slug}@root.vc`, + worksFor: { "@id": ORG_ID }, + sameAs: hasLink(person.linkedin) ? [person.linkedin] : undefined, }; } -// ── Portfolio ───────────────────────────────────────────────────────────────── - -// Deterministic sibling picker: company i links to i+1..i+n, wrapping around. -// Every company gets both outbound and inbound links, and — critically for the -// --check drift guard — the choice never varies between builds. -function siblings(slugs, index, count = 5) { - const picked = []; - for (let offset = 1; offset <= count && offset < slugs.length; offset += 1) { - picked.push(slugs[(index + offset) % slugs.length]); - } - return picked; -} - -function renderPortfolioIndex({ firm, portfolio }) { - const slugs = Object.keys(portfolio); - const items = slugs - .map((slug) => { - const company = portfolio[slug]; - return ( - `
  • \n` + - ` ${esc(company.name)}\n` + - ` ${esc(company.description)}\n` + - `
  • ` - ); - }) - .join("\n"); - - const body = `

    Portfolio

    -

    ${esc(firm.blurb)}

    -

    ${slugs.length} companies.

    - `; - - return { - path: "portfolio/index.html", - content: layout({ - title: "Portfolio — Root Ventures", - description: metaDescription( - `All ${slugs.length} companies backed by Root Ventures, a San Francisco deep tech seed fund investing in robotics, hardware, manufacturing, and developer tools.` - ), - pathname: "/portfolio/", - command: "tldr", - body, - graph: [ - { - "@type": "CollectionPage", - "@id": `${absUrl("/portfolio/")}#page`, - url: absUrl("/portfolio/"), - name: "Root Ventures Portfolio", - isPartOf: { "@id": `${ORIGIN}/#website` }, - about: { "@id": ORG_ID }, - }, - { - "@type": "ItemList", - name: "Root Ventures portfolio companies", - numberOfItems: slugs.length, - itemListElement: slugs.map((slug, index) => ({ - "@type": "ListItem", - position: index + 1, - name: portfolio[slug].name, - url: absUrl(`/portfolio/${slug}/`), - })), - }, - breadcrumbNode([ - ["Root Ventures", "/"], - ["Portfolio", "/portfolio/"], - ]), - ], - }), - }; -} - -function renderCompany({ firm, portfolio }, slug, index) { - const slugs = Object.keys(portfolio); - const company = portfolio[slug]; - const pathname = `/portfolio/${slug}/`; - - const facts = []; - if (hasLink(company.url)) { - facts.push( - `
    Website
    \n
    ${esc(company.url.replace(/^https?:\/\//, ""))}
    ` - ); - } - if (hasLink(company.demo)) { - facts.push( - `
    Try it
    \n
    ${esc(company.demo)}
    ` - ); - } - if (hasLink(company.memo)) { - facts.push( - `
    Investment memo
    \n
    Why we invested
    ` - ); - } - const factList = facts.length - ? `
    \n${facts.join("\n")}\n
    ` - : ""; - - const related = siblings(slugs, index) - .map( - (other) => - `
  • ${esc(portfolio[other].name)} ${esc(portfolio[other].description)}
  • ` - ) - .join("\n"); - - const body = `

    ${esc(company.name)}

    -

    ${esc(company.description)}

    -${factList} -

    ${esc(company.name)} is a ${esc(firm.name)} portfolio company. ${esc(firm.blurb)}

    -

    More from the portfolio

    - -

    All ${slugs.length} portfolio companies

    `; - - const companyNode = { +function companyNode(slug, company) { + const node = { "@type": "Organization", - "@id": `${absUrl(pathname)}#company`, + "@id": `${ORIGIN}/#company-${slug}`, name: company.name, description: company.description, image: `${ORIGIN}/images/${slug}.jpg`, funder: { "@id": ORG_ID }, }; - if (hasLink(company.url)) companyNode.url = company.url; - - return { - path: `portfolio/${slug}/index.html`, - content: layout({ - title: `${company.name} — Root Ventures portfolio`, - description: metaDescription( - `${company.description} ${company.name} is a Root Ventures portfolio company.` - ), - pathname, - command: `tldr ${slug}`, - body, - graph: [ - { - "@type": "WebPage", - "@id": `${absUrl(pathname)}#page`, - url: absUrl(pathname), - name: `${company.name} — Root Ventures portfolio`, - isPartOf: { "@id": `${ORIGIN}/#website` }, - about: { "@id": `${absUrl(pathname)}#company` }, - }, - companyNode, - breadcrumbNode([ - ["Root Ventures", "/"], - ["Portfolio", "/portfolio/"], - [company.name, pathname], - ]), - ], - }), - }; -} - -// ── Team ────────────────────────────────────────────────────────────────────── - -function renderTeamIndex({ firm, team }) { - const slugs = Object.keys(team); - const items = slugs - .map( - (slug) => - `
  • \n` + - ` ${esc(team[slug].name)}\n` + - ` ${esc(team[slug].title)}\n` + - ` ${esc(team[slug].description)}\n` + - `
  • ` - ) - .join("\n"); - - const body = `

    Team

    -

    ${esc(firm.blurb)}

    - `; - - return { - path: "team/index.html", - content: layout({ - title: "Team — Root Ventures", - description: metaDescription( - `The partners and operators behind Root Ventures, a San Francisco deep tech seed fund: ${slugs - .map((slug) => team[slug].name) - .join(", ")}.` - ), - pathname: "/team/", - command: "whois", - body, - graph: [ - { - "@type": "CollectionPage", - "@id": `${absUrl("/team/")}#page`, - url: absUrl("/team/"), - name: "Root Ventures Team", - isPartOf: { "@id": `${ORIGIN}/#website` }, - about: { "@id": ORG_ID }, - }, - { - "@type": "ItemList", - name: "Root Ventures team", - numberOfItems: slugs.length, - itemListElement: slugs.map((slug, index) => ({ - "@type": "ListItem", - position: index + 1, - name: team[slug].name, - url: absUrl(`/team/${slug}/`), - })), - }, - breadcrumbNode([ - ["Root Ventures", "/"], - ["Team", "/team/"], - ]), - ], - }), - }; -} - -function renderPerson({ firm, team }, slug) { - const person = team[slug]; - const pathname = `/team/${slug}/`; - const email = `${slug}@root.vc`; - - const others = Object.keys(team) - .filter((other) => other !== slug) - .map( - (other) => - `
  • ${esc(team[other].name)} ${esc(team[other].title)}
  • ` - ) - .join("\n"); - - const linkedinFact = hasLink(person.linkedin) - ? `
    LinkedIn
    \n
    ${esc(person.name)}
    \n` - : ""; - - const body = `

    ${esc(person.name)}

    -

    ${esc(person.title)}, ${esc(firm.name)}

    -

    ${esc(person.description)}

    -
    -
    Email
    -
    ${esc(email)}
    -${linkedinFact}
    -

    The rest of the team

    - `; - - return { - path: `team/${slug}/index.html`, - content: layout({ - title: `${person.name}, ${person.title} — Root Ventures`, - description: metaDescription( - `${person.name} is ${person.title} at Root Ventures. ${person.description}` - ), - pathname, - command: `whois ${slug}`, - body, - graph: [ - { - "@type": "ProfilePage", - "@id": `${absUrl(pathname)}#page`, - url: absUrl(pathname), - name: `${person.name} — Root Ventures`, - isPartOf: { "@id": `${ORIGIN}/#website` }, - mainEntity: { "@id": `${absUrl(pathname)}#person` }, - }, - { - "@type": "Person", - "@id": `${absUrl(pathname)}#person`, - name: person.name, - jobTitle: person.title, - description: person.description, - url: absUrl(pathname), - image: `${ORIGIN}/images/${slug}.png`, - email, - worksFor: { "@id": ORG_ID }, - sameAs: hasLink(person.linkedin) ? [person.linkedin] : undefined, - }, - breadcrumbNode([ - ["Root Ventures", "/"], - ["Team", "/team/"], - [person.name, pathname], - ]), - ], - }), - }; -} - -// ── About ───────────────────────────────────────────────────────────────────── - -function renderAbout({ firm, portfolio, team }) { - const { streetAddress, addressLocality, addressRegion, postalCode } = firm.address; - - const body = `

    About Root Ventures

    -

    ${esc(firm.blurb)}

    -
    -
    Thesis
    -
    ${esc(firm.thesis)}
    -
    Mission
    -
    ${esc(firm.tagline)}
    -
    Fund size
    -
    ${esc(firm.fundSize)}
    -
    Typical check
    -
    ${esc(firm.checkSize)}
    -
    Stage
    -
    Seed, usually leading the initial round
    -
    Focus
    -
    ${esc(firm.focusAreas.join(", "))}
    -
    Office
    -
    ${esc(streetAddress)}, ${esc(addressLocality)}, ${esc(addressRegion)} ${esc(postalCode)}
    -
    Email
    -
    ${esc(firm.email)}
    -
    -

    Where to go next

    - `; - - return { - path: "about/index.html", - content: layout({ - title: "About — Root Ventures", - description: metaDescription(firm.blurb), - pathname: "/about/", - command: "whois root", - body, - graph: [ - { - "@type": "AboutPage", - "@id": `${absUrl("/about/")}#page`, - url: absUrl("/about/"), - name: "About Root Ventures", - isPartOf: { "@id": `${ORIGIN}/#website` }, - mainEntity: { "@id": ORG_ID }, - }, - organizationNode(firm, team), - breadcrumbNode([ - ["Root Ventures", "/"], - ["About", "/about/"], - ]), - ], - }), - }; -} - -// ── Jobs ────────────────────────────────────────────────────────────────────── - -// jobs[id] is an array of terminal lines: [0] is the title, blank strings -// separate paragraphs, and lines starting with "•" are bullets. Reassemble that -// into real HTML paragraphs and lists. -function renderJobLines(lines) { - const html = []; - let paragraph = []; - let bullets = []; - - const flushParagraph = () => { - if (paragraph.length) { - html.push(`

    ${esc(paragraph.join(" "))}

    `); - paragraph = []; - } - }; - const flushBullets = () => { - if (bullets.length) { - html.push( - ` ` - ); - bullets = []; - } - }; - - for (const raw of lines) { - const line = raw.trim(); - if (line === "") { - flushParagraph(); - flushBullets(); - } else if (line.startsWith("•")) { - flushParagraph(); - bullets.push(line.replace(/^•\s*/, "")); - } else { - flushBullets(); - paragraph.push(line); - } - } - flushParagraph(); - flushBullets(); - - return html.join("\n"); -} - -function renderJobs({ firm, jobs }) { - const ids = Object.keys(jobs); - const sections = ids - .map((id) => { - const [title, ...rest] = jobs[id]; - return `

    ${esc(title)}

    \n${renderJobLines(rest)}`; - }) - .join("\n"); - - const body = `

    Jobs at Root Ventures

    -

    ${esc(firm.blurb)}

    -${sections} -

    How to apply

    -

    Email ${esc(firm.email)}, or run apply ${esc(ids[0])} in the terminal.

    `; - - return { - path: "jobs/index.html", - content: layout({ - title: "Jobs — Root Ventures", - description: metaDescription( - `Open roles at Root Ventures, a San Francisco deep tech seed fund: ${ids - .map((id) => jobs[id][0]) - .join(", ")}.` - ), - pathname: "/jobs/", - command: "jobs", - body, - graph: [ - { - "@type": "WebPage", - "@id": `${absUrl("/jobs/")}#page`, - url: absUrl("/jobs/"), - name: "Jobs at Root Ventures", - isPartOf: { "@id": `${ORIGIN}/#website` }, - about: { "@id": ORG_ID }, - }, - breadcrumbNode([ - ["Root Ventures", "/"], - ["Jobs", "/jobs/"], - ]), - ], - }), - }; + // The company's own site is the better `url`; the deep link is where root.vc + // talks about them, which is what subjectOf means. + if (hasLink(company.url)) node.url = company.url; + node.subjectOf = absUrl(deepLink(COMMANDS.company(slug))); + return node; } // ── robots.txt / sitemap.xml / llms.txt ─────────────────────────────────────── @@ -713,14 +226,14 @@ const AI_CRAWLERS = [ ]; function renderRobots() { - // netlify.toml publishes the repo root, so build tooling is served too. Keep - // that out of indexes — but never /js/, /css/, or /config/: crawlers have to - // fetch all three to render the terminal and welcome.htm, both of which build - // their content from config/*.js at runtime. + // Nothing that carries content is disallowed — never /js/, /css/, or + // /config/, since crawlers have to fetch all three to render the terminal and + // welcome.htm, both of which build their content from config/*.js at runtime. const blocks = [ "# root.vc — https://root.vc", - "# AI crawlers are welcome here. The plain-text mirror of the terminal", - "# lives at /portfolio/, /team/, /about/, /jobs/, and in /llms.txt.", + "# AI crawlers are welcome here. The whole site is one page: the terminal", + "# at / carries every company and person as text, and /llms.txt repeats it", + "# as prose.", "", "User-agent: *", "Allow: /", @@ -749,6 +262,53 @@ ${entries} `; } +// ── Redirects ───────────────────────────────────────────────────────────────── + +// Every URL the old static mirror occupied now redirects into the terminal at +// the command that shows the same content. +// +// 301 rather than letting them 404. Google drops a redirected URL and folds its +// signals into the destination, where a 404 just decays, and anyone following an +// old link from elsewhere still lands on the right thing instead of an error. +// +// Netlify matches these top to bottom and takes the first hit, so the exact +// paths have to precede the :slug patterns that would otherwise swallow them. +// Both the bare and trailing-slash forms are listed rather than relying on +// Netlify's normalization. +function renderRedirects() { + const exact = [ + ["/about", COMMANDS.about], + ["/jobs", COMMANDS.jobs], + ["/portfolio", COMMANDS.portfolioIndex], + ["/team", COMMANDS.teamIndex], + ]; + + const lines = [ + "# Generated by scripts/build-pages.js — do not edit.", + "# The static mirror's URLs, pointed at the terminal commands that replaced", + "# them. See the header of build-pages.js for why the mirror is gone.", + "", + ]; + + for (const [pathname, command] of exact) { + lines.push(`${pathname}/ ${deepLink(command)} 301`); + lines.push(`${pathname} ${deepLink(command)} 301`); + } + + lines.push(""); + // :slug is a Netlify placeholder, echoed into the destination. One rule per + // shape covers every company and person without enumerating them. + lines.push("/portfolio/:slug/ /#tldr-:slug 301"); + lines.push("/portfolio/:slug /#tldr-:slug 301"); + lines.push("/team/:slug/ /#whois-:slug 301"); + lines.push("/team/:slug /#whois-:slug 301"); + lines.push(""); + + return lines.join("\n"); +} + +// ── llms.txt ────────────────────────────────────────────────────────────────── + function renderLlmsTxt({ firm, portfolio, team, jobs }) { const lines = [ `# ${firm.name}`, @@ -757,12 +317,12 @@ function renderLlmsTxt({ firm, portfolio, team, jobs }) { "", `${firm.name} is a seed-stage venture capital firm in ${firm.address.addressLocality}, ${firm.address.addressRegion}. Mission: ${firm.tagline}. Thesis: ${firm.thesis} Fund size ${firm.fundSize}, typical check ${firm.checkSize}. Focus areas: ${firm.focusAreas.join(", ")}.`, "", - `The website at ${ORIGIN} is an interactive terminal; this file and the pages it links are the plain-text equivalent.`, + `The website at ${ORIGIN} is an interactive terminal and is the only page. Each link below opens it with the matching command already run; the content is also inline in this file and in /llms-full.txt.`, "", "## About", "", - `- [About ${firm.name}](${absUrl("/about/")}): fund details, thesis, office, and contact.`, - `- [Jobs](${absUrl("/jobs/")}): ${ + `- [About ${firm.name}](${absUrl(deepLink(COMMANDS.about))}): fund details, thesis, office, and contact.`, + `- [Jobs](${absUrl(deepLink(COMMANDS.jobs))}): ${ Object.keys(jobs).length ? `open roles — ${Object.keys(jobs) .map((id) => jobs[id][0]) @@ -776,14 +336,14 @@ function renderLlmsTxt({ firm, portfolio, team, jobs }) { for (const slug of Object.keys(team)) { lines.push( - `- [${team[slug].name}](${absUrl(`/team/${slug}/`)}): ${team[slug].title}. ${team[slug].description}` + `- [${team[slug].name}](${absUrl(deepLink(COMMANDS.person(slug)))}): ${team[slug].title}. ${team[slug].description}` ); } lines.push("", "## Portfolio", ""); for (const slug of Object.keys(portfolio)) { lines.push( - `- [${portfolio[slug].name}](${absUrl(`/portfolio/${slug}/`)}): ${portfolio[slug].description}` + `- [${portfolio[slug].name}](${absUrl(deepLink(COMMANDS.company(slug)))}): ${portfolio[slug].description}` ); } @@ -819,7 +379,7 @@ function renderLlmsFull({ firm, portfolio, team, jobs }) { lines.push(`### ${person.name} — ${person.title}`, "", person.description, ""); lines.push(`Email: ${slug}@root.vc`); if (hasLink(person.linkedin)) lines.push(`LinkedIn: ${person.linkedin}`); - lines.push(`Page: ${absUrl(`/team/${slug}/`)}`, ""); + lines.push(`Terminal: ${absUrl(deepLink(COMMANDS.person(slug)))}`, ""); } lines.push(`## Portfolio (${Object.keys(portfolio).length} companies)`, ""); @@ -829,7 +389,7 @@ function renderLlmsFull({ firm, portfolio, team, jobs }) { if (hasLink(company.url)) lines.push(`Website: ${company.url}`); if (hasLink(company.demo)) lines.push(`Demo: ${company.demo}`); if (hasLink(company.memo)) lines.push(`Investment memo: ${company.memo}`); - lines.push(`Page: ${absUrl(`/portfolio/${slug}/`)}`, ""); + lines.push(`Terminal: ${absUrl(deepLink(COMMANDS.company(slug)))}`, ""); } lines.push("## Open roles", ""); @@ -851,26 +411,27 @@ function renderLlmsFull({ firm, portfolio, team, jobs }) { // ── index.html crawlable index injection ───────────────────────────────────── // Most LLM crawlers do not execute JavaScript, so on the homepage they see an -// empty
    . This block gives them the whole map. +// empty
    . This block gives them the whole map, and with the +// mirror gone it is the only place that content exists as HTML. // -// It is a visually-hidden div rather than