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}
-
-
-
`;
-
- 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