The site says who it is for, how to switch, and what it costs a team; search engines get a map
From the site audit: the hero names engineering teams running AI coding agents; coordination leads with what works today (forks, conflicts on push, the merge queue) and keeps claims and handoffs as coming soon; a Switching section covers importing, mirroring or moving from GitHub, what keeps working, what holds if g1t goes away, and who it is not a fit for yet. Pricing gets a title that names the model, a Recommended badge, a line on what the plan is for, a per-team anchor and a switching question. robots.txt is served from the request's origin and names /sitemap.xml, which lists the public pages and Explore's public projects. The home page carries Organization and SoftwareApplication JSON-LD; pricing carries the application with its price and FAQPage for its questions. Testimonials and a founder block are wired but empty: each shows once real content is filled in (TESTIMONIALS in landing.tsx, FOUNDER in lib/legal.ts).
| 10 | 10 | import type { User } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Mark } from "./logo"; | |
| 13 | − | import { COMPANY, MAKER_PRODUCTS, copyright, listed } from "../lib/legal"; | |
| 13 | + | import { COMPANY, FOUNDER, MAKER_PRODUCTS, copyright, listed } from "../lib/legal"; | |
| 14 | 14 | import { type OverallState, STATUS_JSON_URL, STATUS_URL, STATUS_WORDS, type StatusReport, dotClass } from "../lib/status"; | |
| 15 | 15 | ||
| 16 | 16 | /** How long one fetched report is reused across the pages of one visit. */ | |
| ⋯ | |||
| 156 | 156 | ); | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | + | /** Who builds g1t, by name, with their photo and their own words. Nothing until FOUNDER is filled in. */ | |
| 160 | + | function FounderBlock() { | |
| 161 | + | if (!FOUNDER) return null; | |
| 162 | + | return ( | |
| 163 | + | <figure className="mx-auto mb-6 flex max-w-xl flex-col items-center gap-4 text-center sm:flex-row sm:items-start sm:text-left"> | |
| 164 | + | <img src={FOUNDER.photo} alt="" width={56} height={56} className="size-14 shrink-0 rounded-full ring-1 ring-line" /> | |
| 165 | + | <div> | |
| 166 | + | <blockquote className="text-sm leading-6 text-fg-soft">{FOUNDER.why}</blockquote> | |
| 167 | + | <figcaption className="mt-2 text-sm text-muted"> | |
| 168 | + | <span className="font-medium text-fg">{FOUNDER.name}</span>, {FOUNDER.role} | |
| 169 | + | {FOUNDER.links.map((link) => ( | |
| 170 | + | <a key={link.url} href={link.url} rel="me noopener" className="ml-3 hover:text-fg hover:underline hover:underline-offset-4"> | |
| 171 | + | {link.label} | |
| 172 | + | </a> | |
| 173 | + | ))} | |
| 174 | + | </figcaption> | |
| 175 | + | </div> | |
| 176 | + | </figure> | |
| 177 | + | ); | |
| 178 | + | } | |
| 179 | + | ||
| 159 | 180 | /** "g1t is designed, built, and backed by Flagon, Inc., …" */ | |
| 160 | 181 | export function MakerLine() { | |
| 161 | 182 | const products = MAKER_PRODUCTS.map((product) => product.name); | |
| ⋯ | |||
| 254 | 275 | </nav> | |
| 255 | 276 | <div className="border-t border-line"> | |
| 256 | 277 | <div className="mx-auto max-w-3xl space-y-4 px-4 py-10 text-center"> | |
| 278 | + | <FounderBlock /> | |
| 257 | 279 | <MakerLine /> | |
| 258 | 280 | <LegalRow user={user} /> | |
| 259 | 281 | </div> | |
| 300 | 300 | }, | |
| 301 | 301 | ]; | |
| 302 | 302 | ||
| 303 | + | /** | |
| 304 | + | * What people using g1t say, each with their name, role, workspace and a | |
| 305 | + | * specific outcome. None yet: the section shows once real, permitted | |
| 306 | + | * quotes are here. Never write one on someone's behalf. | |
| 307 | + | */ | |
| 308 | + | const TESTIMONIALS = [] as { quote: string; name: string; role: string; workspace: string; avatar: string }[]; | |
| 309 | + | ||
| 303 | 310 | /** A workspace's org chart, read like a company's: g1t knows everyone; each department has its colleague. */ | |
| 304 | 311 | const ORG: { team: string; who: string }[] = [ | |
| 305 | 312 | { team: "Engineering", who: "Otto" }, | |
| ⋯ | |||
| 353 | 360 | to={`${DOCS}/guides/chat/`} | |
| 354 | 361 | className="inline-flex animate-fade-up items-center gap-2 rounded-full bg-bg/50 px-3 py-1 text-xs text-fg-soft ring-1 ring-white/10 backdrop-blur transition-colors hover:bg-bg/70" | |
| 355 | 362 | > | |
| 356 | − | <span className="size-1.5 rounded-full bg-accent" /> | |
| 357 | − | Chat, agents, docs and code in one workspace | |
| 358 | − | <ArrowRight size={12} /> | |
| 363 | + | <span className="size-1.5 shrink-0 rounded-full bg-accent" /> | |
| 364 | + | For engineering teams running AI coding agents: chat, agents, docs and code in one workspace | |
| 365 | + | <ArrowRight size={12} className="shrink-0" /> | |
| 359 | 366 | </Link> | |
| 360 | 367 | <h1 className="mx-auto mt-7 max-w-4xl animate-fade-up text-[2.75rem] leading-[1.04] font-semibold tracking-tight text-balance sm:text-7xl"> | |
| 361 | 368 | Your team and its agents,{" "} | |
| ⋯ | |||
| 612 | 619 | </div> | |
| 613 | 620 | </section> | |
| 614 | 621 | ||
| 615 | − | {/* Coming next: coordination and Docs. */} | |
| 622 | + | {/* Switching: what moving in costs, and what holds if g1t goes away. */} | |
| 623 | + | <section id="switching" className="mx-auto max-w-6xl scroll-mt-20 px-4 pb-24"> | |
| 624 | + | <div className="grid gap-10 rounded-3xl bg-surface p-8 ring-1 ring-line sm:p-10 lg:grid-cols-[1fr_1.1fr] lg:gap-16"> | |
| 625 | + | <div> | |
| 626 | + | <Eyebrow>Switching</Eyebrow> | |
| 627 | + | <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance"> | |
| 628 | + | Moving in is an import. Leaving is a git clone. | |
| 629 | + | </h2> | |
| 630 | + | <p className="mt-4 max-w-xl leading-7 text-muted"> | |
| 631 | + | Bring repositories across from GitHub in a few clicks: every branch and tag with full history, and their | |
| 632 | + | issues if you want them. Import a copy, keep a mirror that follows GitHub while you try g1t, or move and | |
| 633 | + | let g1t push back to GitHub so people still working there see every change. | |
| 634 | + | </p> | |
| 635 | + | <p className="mt-4 max-w-xl leading-7 text-muted"> | |
| 636 | + | If g1t went away tomorrow, your code is plain git, the source is MIT licensed, and the core forge runs on | |
| 637 | + | your own machine with Docker Compose. | |
| 638 | + | </p> | |
| 639 | + | <More to={`${DOCS}/guides/github/#import-mirror-or-move-a-repository`}>Import from GitHub</More> | |
| 640 | + | </div> | |
| 641 | + | <div className="space-y-6"> | |
| 642 | + | <div> | |
| 643 | + | <p className="text-sm font-medium">What keeps working</p> | |
| 644 | + | <Points | |
| 645 | + | columns={false} | |
| 646 | + | points={[ | |
| 647 | + | "git over HTTPS, with your history as it is", | |
| 648 | + | "GitHub Actions workflows: rename .github to .g1t and push", | |
| 649 | + | "The coding agents you use today, through MCP", | |
| 650 | + | "Leaving: git clone, and the REST API for the rest", | |
| 651 | + | ]} | |
| 652 | + | /> | |
| 653 | + | </div> | |
| 654 | + | <div className="rounded-xl border border-dashed border-line-strong px-4 py-3.5 text-sm leading-6 text-muted"> | |
| 655 | + | <p className="font-medium text-fg-soft">Not a fit yet if you need</p> | |
| 656 | + | <p className="mt-1.5"> | |
| 657 | + | Single sign-on, git over SSH, or agents, chat and deployments on your own machines: those run only on | |
| 658 | + | g1t.sh today. Open pull requests, issue comments, releases and wikis stay behind on import.{" "} | |
| 659 | + | <Link to={`${DOCS}/about/limitations/`} className="text-fg-soft underline-offset-4 hover:text-accent hover:underline"> | |
| 660 | + | What g1t can't do yet | |
| 661 | + | </Link> | |
| 662 | + | </p> | |
| 663 | + | </div> | |
| 664 | + | </div> | |
| 665 | + | </div> | |
| 666 | + | </section> | |
| 667 | + | ||
| 668 | + | {/* Coordination, as it works today; and Docs, coming next. */} | |
| 616 | 669 | <section className="mx-auto grid max-w-6xl gap-4 px-4 pb-24 lg:grid-cols-2"> | |
| 617 | 670 | <div className="rounded-3xl bg-surface p-8 ring-1 ring-line"> | |
| 618 | − | <div className="flex items-center gap-3"> | |
| 619 | − | <Eyebrow>Coordination</Eyebrow> | |
| 620 | − | <Soon /> | |
| 621 | − | </div> | |
| 671 | + | <Eyebrow>Coordination</Eyebrow> | |
| 622 | 672 | <h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">Many agents, no collisions</h3> | |
| 623 | 673 | <p className="mt-3 text-sm leading-6 text-muted"> | |
| 624 | − | Before an agent touches an issue, a branch, an environment or a set of paths, it claims it. When two | |
| 625 | − | agents' work would overlap, they agree who goes first in a thread you can read. Chains of agents stop | |
| 626 | − | and ask a person after a few hops, and work done for another agent is charged to the task that asked. | |
| 674 | + | Every agent already sees what the others are changing and can ask them, through the forge. Each change is a | |
| 675 | + | pull request in the agent's own fork, conflicts are found on every push, and the merge queue tests | |
| 676 | + | changes together with what lands ahead of them. Five agents can open pull requests the same afternoon and | |
| 677 | + | main still only moves to what passed. | |
| 627 | 678 | </p> | |
| 628 | − | <p className="mt-3 text-sm leading-6 text-muted"> | |
| 629 | − | Today, every agent already sees what the others are changing and can ask them, through the forge. | |
| 679 | + | <p className="mt-4 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm leading-6 text-muted"> | |
| 680 | + | <Soon /> | |
| 681 | + | <span> | |
| 682 | + | Claims and handoffs: an agent claims an issue, branch or set of paths before it starts, and overlapping | |
| 683 | + | agents agree who goes first in a thread you can read. | |
| 684 | + | </span> | |
| 630 | 685 | </p> | |
| 686 | + | <More to={`${DOCS}/guides/merge-queue/`}>The merge queue</More> | |
| 631 | 687 | </div> | |
| 632 | 688 | <div className="rounded-3xl bg-surface p-8 ring-1 ring-line"> | |
| 633 | 689 | <div className="flex items-center gap-3"> | |
| ⋯ | |||
| 644 | 700 | </div> | |
| 645 | 701 | </section> | |
| 646 | 702 | ||
| 703 | + | {/* What people using it say. Nothing until there are real quotes. */} | |
| 704 | + | {TESTIMONIALS.length > 0 && ( | |
| 705 | + | <section className="mx-auto max-w-6xl px-4 pb-24"> | |
| 706 | + | <Eyebrow>From the teams using it</Eyebrow> | |
| 707 | + | <ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| 708 | + | {TESTIMONIALS.map((item) => ( | |
| 709 | + | <li key={item.name} className="flex flex-col rounded-2xl bg-surface p-6 ring-1 ring-line"> | |
| 710 | + | <blockquote className="grow text-sm leading-6 text-fg-soft">“{item.quote}”</blockquote> | |
| 711 | + | <p className="mt-5 flex items-center gap-3 text-sm"> | |
| 712 | + | <img src={item.avatar} alt="" width={32} height={32} className="size-8 rounded-full ring-1 ring-line" /> | |
| 713 | + | <span className="min-w-0 leading-tight"> | |
| 714 | + | <span className="block font-medium text-fg">{item.name}</span> | |
| 715 | + | <span className="block truncate text-xs text-muted"> | |
| 716 | + | {item.role}, {item.workspace} | |
| 717 | + | </span> | |
| 718 | + | </span> | |
| 719 | + | </p> | |
| 720 | + | </li> | |
| 721 | + | ))} | |
| 722 | + | </ul> | |
| 723 | + | </section> | |
| 724 | + | )} | |
| 725 | + | ||
| 647 | 726 | {/* How it flows */} | |
| 648 | 727 | <section id="how" className="scroll-mt-20 border-y border-line bg-surface/40"> | |
| 649 | 728 | <div className="mx-auto max-w-6xl px-4 py-24"> | |
| 13 | 13 | }; | |
| 14 | 14 | ||
| 15 | 15 | /** | |
| 16 | + | * The person behind g1t, for the footer's founder block. None yet: the | |
| 17 | + | * block shows once a real name, photo, links and their own words are here. | |
| 18 | + | */ | |
| 19 | + | export type Founder = { | |
| 20 | + | name: string; | |
| 21 | + | role: string; | |
| 22 | + | /** A square photo, served from g1t's own assets or usercontent. */ | |
| 23 | + | photo: string; | |
| 24 | + | /** One paragraph, in their words: why they built g1t. */ | |
| 25 | + | why: string; | |
| 26 | + | links: { label: string; url: string }[]; | |
| 27 | + | }; | |
| 28 | + | export const FOUNDER = null as Founder | null; | |
| 29 | + | ||
| 30 | + | /** | |
| 16 | 31 | * Flagon's other launched products, which the footer's maker line names | |
| 17 | 32 | * ("…, the people behind X and Y"). None yet: add each here when it ships. | |
| 18 | 33 | */ |
| 1 | + | /** | |
| 2 | + | * Schema.org JSON-LD for the pages that say what g1t is and what it costs, | |
| 3 | + | * so search engines and AI assistants read it without parsing the prose. | |
| 4 | + | * Each is a `script:ld+json` meta descriptor, added after `page(…)`. | |
| 5 | + | */ | |
| 6 | + | import type { MetaDescriptor } from "react-router"; | |
| 7 | + | ||
| 8 | + | import { COMPANY } from "./legal"; | |
| 9 | + | import { DESCRIPTION, SITE } from "./meta"; | |
| 10 | + | ||
| 11 | + | export function organization(): MetaDescriptor { | |
| 12 | + | return { | |
| 13 | + | "script:ld+json": { | |
| 14 | + | "@context": "https://schema.org", | |
| 15 | + | "@type": "Organization", | |
| 16 | + | name: COMPANY.name, | |
| 17 | + | url: COMPANY.url, | |
| 18 | + | brand: { "@type": "Brand", name: "g1t", url: SITE }, | |
| 19 | + | }, | |
| 20 | + | }; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /** g1t as an application, with the plan's monthly price per workspace. */ | |
| 24 | + | export function application(monthlyDollars: number): MetaDescriptor { | |
| 25 | + | return { | |
| 26 | + | "script:ld+json": { | |
| 27 | + | "@context": "https://schema.org", | |
| 28 | + | "@type": "SoftwareApplication", | |
| 29 | + | name: "g1t", | |
| 30 | + | url: SITE, | |
| 31 | + | applicationCategory: "DeveloperApplication", | |
| 32 | + | operatingSystem: "Web", | |
| 33 | + | description: DESCRIPTION, | |
| 34 | + | publisher: { "@type": "Organization", name: COMPANY.name, url: COMPANY.url }, | |
| 35 | + | license: "https://opensource.org/licenses/MIT", | |
| 36 | + | offers: [ | |
| 37 | + | { | |
| 38 | + | "@type": "Offer", | |
| 39 | + | name: "Free", | |
| 40 | + | price: "0", | |
| 41 | + | priceCurrency: "USD", | |
| 42 | + | description: "Chat and the forge, with no card.", | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | "@type": "Offer", | |
| 46 | + | name: "The g1t plan", | |
| 47 | + | price: String(monthlyDollars), | |
| 48 | + | priceCurrency: "USD", | |
| 49 | + | description: "A month per workspace, never per seat, with $10 of usage included.", | |
| 50 | + | priceSpecification: { | |
| 51 | + | "@type": "UnitPriceSpecification", | |
| 52 | + | price: String(monthlyDollars), | |
| 53 | + | priceCurrency: "USD", | |
| 54 | + | unitText: "month per workspace", | |
| 55 | + | }, | |
| 56 | + | }, | |
| 57 | + | ], | |
| 58 | + | }, | |
| 59 | + | }; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | export function faqPage(questions: { q: string; a: string }[]): MetaDescriptor { | |
| 63 | + | return { | |
| 64 | + | "script:ld+json": { | |
| 65 | + | "@context": "https://schema.org", | |
| 66 | + | "@type": "FAQPage", | |
| 67 | + | mainEntity: questions.map((item) => ({ | |
| 68 | + | "@type": "Question", | |
| 69 | + | name: item.q, | |
| 70 | + | acceptedAnswer: { "@type": "Answer", text: item.a }, | |
| 71 | + | })), | |
| 72 | + | }, | |
| 73 | + | }; | |
| 74 | + | } |
| 75 | 75 | route("status", "routes/status.tsx"), | |
| 76 | 76 | route("status.json", "routes/status-json.ts"), | |
| 77 | 77 | route(".well-known/security.txt", "routes/security-txt.ts"), | |
| 78 | + | // For search engines: what may be crawled, and a map of the public pages. | |
| 79 | + | route("robots.txt", "routes/robots-txt.ts"), | |
| 80 | + | route("sitemap.xml", "routes/sitemap-xml.ts"), | |
| 78 | 81 | // Releases of the self-hosted runner and the g1t CLI, from R2. | |
| 79 | 82 | route("downloads/:tool/*", "routes/downloads-runner.ts"), | |
| 80 | 83 | // A workspace's own pages sit under `-`, which no repository can be named. |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/home"; | |
| 5 | 5 | import { page } from "../lib/meta"; | |
| 6 | + | import { application, organization } from "../lib/structured-data"; | |
| 6 | 7 | import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice"; | |
| 7 | 8 | import { readCookie } from "../lib/mission"; | |
| 8 | 9 | import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate"; | |
| ⋯ | |||
| 12 | 13 | import { homePath } from "../lib/workspace-nav"; | |
| 13 | 14 | ||
| 14 | 15 | export function meta(args: Route.MetaArgs) { | |
| 15 | − | return page(args, { | |
| 16 | − | title: "g1t · Your team and its agents, working in one place", | |
| 17 | − | description: | |
| 18 | − | "Chat with your team and your agents in channels and DMs. Agents are teammates with a role, a personality and a budget, and their code lands through pull requests, checks and a merge queue. Chat is free on every plan, never per seat.", | |
| 19 | − | }); | |
| 16 | + | return [ | |
| 17 | + | ...page(args, { | |
| 18 | + | title: "g1t · Your team and its agents, working in one place", | |
| 19 | + | description: | |
| 20 | + | "Chat with your team and your agents in channels and DMs. Agents are teammates with a role, a personality and a budget, and their code lands through pull requests, checks and a merge queue. Chat is free on every plan, never per seat.", | |
| 21 | + | }), | |
| 22 | + | organization(), | |
| 23 | + | application(20), | |
| 24 | + | ]; | |
| 20 | 25 | } | |
| 21 | 26 | ||
| 22 | 27 | /** | |
| 5 | 5 | import type { Route } from "./+types/pricing"; | |
| 6 | 6 | import { wholeDollars } from "../lib/billing"; | |
| 7 | 7 | import { page } from "../lib/meta"; | |
| 8 | + | import { application, faqPage } from "../lib/structured-data"; | |
| 8 | 9 | import { TimeAgo } from "../components/ui"; | |
| 9 | 10 | import { billing } from "../lib/services.server"; | |
| 10 | 11 | ||
| 11 | 12 | export function meta(args: Route.MetaArgs) { | |
| 12 | − | return page(args, { | |
| 13 | − | title: "Pricing · g1t", | |
| 14 | − | description: | |
| 15 | − | "People chat free on every plan, and the forge is free. One plan, $20 a month per workspace with $10 of usage included. Agents pay the model's price plus a flat agent rate, from each agent's budget. No seats, ever.", | |
| 16 | − | }); | |
| 13 | + | const plan = args.loaderData?.book?.plans?.find((p) => p.feature === "plan") ?? DEFAULT_PLAN; | |
| 14 | + | return [ | |
| 15 | + | ...page(args, { | |
| 16 | + | title: `g1t pricing - $${plan.monthlyCents / 100} per workspace, never per seat, cost-plus AI`, | |
| 17 | + | description: | |
| 18 | + | "People chat free on every plan, and the forge is free. One plan, $20 a month per workspace with $10 of usage included. Agents pay the model's price plus a flat agent rate, from each agent's budget. No seats, ever.", | |
| 19 | + | }), | |
| 20 | + | application(plan.monthlyCents / 100), | |
| 21 | + | faqPage(FAQ), | |
| 22 | + | ]; | |
| 17 | 23 | } | |
| 18 | 24 | ||
| 19 | 25 | export async function loader() { | |
| ⋯ | |||
| 148 | 154 | a: "No. There are no seats. People who only chat, and ask agents questions, cost nothing until their questions use an agent; then the agent's reply is charged like any other.", | |
| 149 | 155 | }, | |
| 150 | 156 | { | |
| 157 | + | q: "What does moving from GitHub cost, and can we leave again?", | |
| 158 | + | a: "Moving in is an import: bring repositories across from GitHub with every branch, tag and their issues, or keep a mirror that follows GitHub while you try g1t. Your code is plain git and g1t is MIT licensed, so leaving is a git clone, and the core forge runs on your own machine with Docker Compose.", | |
| 159 | + | }, | |
| 160 | + | { | |
| 151 | 161 | q: "Is Docs included?", | |
| 152 | 162 | a: "Docs is coming soon. When it ships, pages, editing and history are included on every plan, like chat. Files are on the storage meter.", | |
| 153 | 163 | }, | |
| ⋯ | |||
| 312 | 322 | ||
| 313 | 323 | <section className="mt-10 rounded-xl border border-accent/40 bg-surface p-6"> | |
| 314 | 324 | <div className="flex flex-wrap items-baseline justify-between gap-3"> | |
| 315 | − | <h2 className="text-xl font-semibold tracking-tight">{plan.title}</h2> | |
| 325 | + | <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 326 | + | {plan.title} | |
| 327 | + | <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs font-medium tracking-normal text-accent">Recommended</span> | |
| 328 | + | </h2> | |
| 316 | 329 | <p> | |
| 317 | 330 | <span className="text-3xl font-semibold tabular-nums">${price}</span>{" "} | |
| 318 | 331 | <span className="text-sm text-muted">a month per workspace</span> | |
| ⋯ | |||
| 321 | 334 | </span> | |
| 322 | 335 | </p> | |
| 323 | 336 | </div> | |
| 337 | + | <p className="mt-2 text-sm text-muted"> | |
| 338 | + | The plan for a team whose agents do real work: unlimited members, agents, checks, the merge queue and | |
| 339 | + | deployments. | |
| 340 | + | </p> | |
| 341 | + | <p className="mt-3 text-sm text-fg-soft"> | |
| 342 | + | One price for the whole team: a 10-person team pays ${price} a month, not ${price} a person. | |
| 343 | + | </p> | |
| 324 | 344 | <ul className="mt-4 space-y-1.5 text-sm text-muted"> | |
| 325 | 345 | {plan.includes.map((line) => ( | |
| 326 | 346 | <li key={line} className="flex gap-2"> | |
| 1 | + | /** | |
| 2 | + | * /robots.txt: everything may be crawled, and where the site's maps are. | |
| 3 | + | * Served from the request's own origin, so a self-hosted g1t names itself. | |
| 4 | + | */ | |
| 5 | + | import type { Route } from "./+types/robots-txt"; | |
| 6 | + | ||
| 7 | + | /** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */ | |
| 8 | + | const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml"; | |
| 9 | + | ||
| 10 | + | export function loader({ request }: Route.LoaderArgs) { | |
| 11 | + | const site = new URL(request.url).origin; | |
| 12 | + | const lines = ["User-agent: *", "Allow: /", "", `Sitemap: ${site}/sitemap.xml`]; | |
| 13 | + | if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`); | |
| 14 | + | return new Response(`${lines.join("\n")}\n`, { | |
| 15 | + | headers: { | |
| 16 | + | "content-type": "text/plain; charset=utf-8", | |
| 17 | + | "cache-control": "public, max-age=86400", | |
| 18 | + | }, | |
| 19 | + | }); | |
| 20 | + | } |
| 1 | + | /** | |
| 2 | + | * /sitemap.xml: the public pages, then the public projects Explore lists, | |
| 3 | + | * newest first. Explore being unreachable leaves just the pages. | |
| 4 | + | */ | |
| 5 | + | import type { Route } from "./+types/sitemap-xml"; | |
| 6 | + | import { search } from "../lib/services.server"; | |
| 7 | + | ||
| 8 | + | /** Pages anyone can read, signed in or not. */ | |
| 9 | + | const PAGES = ["/", "/pricing", "/explore", "/support", "/security", "/policies", "/policies/privacy"]; | |
| 10 | + | ||
| 11 | + | /** How many of Explore's pages to list; each holds one page of projects. */ | |
| 12 | + | const EXPLORE_PAGES = 10; | |
| 13 | + | ||
| 14 | + | function escape(text: string): string { | |
| 15 | + | return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export async function loader({ request }: Route.LoaderArgs) { | |
| 19 | + | const site = new URL(request.url).origin; | |
| 20 | + | const entries: { loc: string; lastmod?: string }[] = PAGES.map((path) => ({ loc: `${site}${path}` })); | |
| 21 | + | for (let pageNumber = 1; pageNumber <= EXPLORE_PAGES; pageNumber++) { | |
| 22 | + | const explore = await search.explore(null, { sort: "new", page: pageNumber }).catch(() => null); | |
| 23 | + | if (!explore) break; | |
| 24 | + | for (const repo of explore.repos) { | |
| 25 | + | const lastmod = (repo.pushedAt ?? repo.createdAt)?.slice(0, 10); | |
| 26 | + | entries.push({ loc: `${site}/${repo.namespace}/${repo.name}`, lastmod }); | |
| 27 | + | } | |
| 28 | + | if (!explore.more) break; | |
| 29 | + | } | |
| 30 | + | const body = [ | |
| 31 | + | '<?xml version="1.0" encoding="UTF-8"?>', | |
| 32 | + | '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', | |
| 33 | + | ...entries.map( | |
| 34 | + | (entry) => ` <url><loc>${escape(entry.loc)}</loc>${entry.lastmod ? `<lastmod>${entry.lastmod}</lastmod>` : ""}</url>`, | |
| 35 | + | ), | |
| 36 | + | "</urlset>", | |
| 37 | + | "", | |
| 38 | + | ].join("\n"); | |
| 39 | + | return new Response(body, { | |
| 40 | + | headers: { | |
| 41 | + | "content-type": "application/xml; charset=utf-8", | |
| 42 | + | "cache-control": "public, max-age=3600", | |
| 43 | + | }, | |
| 44 | + | }); | |
| 45 | + | } |
| 1 | − | User-agent: * | |
| 2 | − | Allow: / |