From 158944c863e41612b917b6a1efca03549534c2d7 Mon Sep 17 00:00:00 2001 From: Lee Edwards Date: Wed, 12 Aug 2026 11:56:37 -0700 Subject: [PATCH] Delete the static mirror; the terminal is the only page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror existed to make an xterm.js terminal readable to crawlers, and it worked well enough to cause the problem it now has: Google indexed all 70-odd pages and began showing sitelinks to About / Team / Jobs under the root.vc result, advertising a conventional website sitting behind the terminal. The terminal is the product, so the website goes. Content is addressed by URL fragment instead. js/terminal-ext.js already read the hash as a command to run on load, so /#tldr-chargelab shows ChargeLab inside the terminal rather than on a page pretending not to be one. - Every old mirror URL 301s to its command via a generated _redirects. Not a 404: a redirect tells Google to drop the indexed URL and fold its signals into the homepage, and an inbound link from elsewhere still lands somewhere real. - The homepage's offscreen block is now the only HTML copy of the portfolio and team, with deep links in place of paths. - Person and company schema.org nodes move into the homepage @graph, keyed by fragment @ids so each stays individually addressable. - llms.txt and llms-full.txt point at deep links. - The `www` command advertised /about/ and /portfolio/. Those now redirect back into the terminal, so it would have sent people in a circle; it points at llms.txt now. Two things this deliberately gives up. Google strips fragments before indexing, so root.vc can no longer rank for "root ventures chargelab" with a dedicated result — one page cannot hold 76 topics. And a deep link shares as the generic site card, since Open Graph tags are per-URL. Both are the cost of having exactly one indexed URL, which is the point. The hash parser now splits the command from its argument on the FIRST hyphen. Splitting on every hyphen turned a hyphenated slug into two arguments, so a company added as `vibe-robotics` would have broken its own deep link — fine while pages existed, not fine when the fragment is the only address. Deep-link arrivals are also tracked now; fragments fire no pageview, so they were invisible. Co-Authored-By: Claude Opus 5 --- config/commands.js | 11 +- css/pages.css | 227 ----------- js/terminal-ext.js | 20 +- scripts/build-assets.js | 1 - scripts/build-pages.js | 780 ++++++++----------------------------- tests/build-pages.test.js | 389 +++++++++--------- tests/terminal-ext.test.js | 24 +- 7 files changed, 421 insertions(+), 1031 deletions(-) delete mode 100644 css/pages.css 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.

    -
      -${items} -
    `; - - 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

    -
      -${related} -
    -

    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)}

    -
      -${items} -
    `; - - 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

    -
      -${others} -
    `; - - 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

    -
      -
    • Portfolio ${Object.keys(portfolio).length} companies we have backed.
    • -
    • Team The ${Object.keys(team).length} people behind the fund.
    • -
    • Jobs Open roles at Root Ventures.
    • -
    `; - - 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( - `
      \n${bullets.map((b) => `
    • ${esc(b)}
    • `).join("\n")}\n
    ` - ); - 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