Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
- "README.md"
- "apps/docs/**"
- "packages/hyperliquid/README.md"
- "packages/hyperliquid/package.json"
- ".github/CODEOWNERS"
- ".github/workflows/docs.yml"
- ".lycheeignore"
Expand All @@ -20,6 +21,7 @@ on:
- "README.md"
- "apps/docs/**"
- "packages/hyperliquid/README.md"
- "packages/hyperliquid/package.json"
- ".github/CODEOWNERS"
- ".github/workflows/docs.yml"
- ".lycheeignore"
Expand Down Expand Up @@ -62,6 +64,10 @@ jobs:
working-directory: apps/docs
run: bun run check

- name: Test documentation website helpers
working-directory: apps/docs
run: bun run test

- name: Build static documentation website
working-directory: apps/docs
run: bun run build
Expand Down
8 changes: 6 additions & 2 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,12 @@ The build verifies every documentation page, internal link, asset, and anchor, p

The build also exports files that let AI agents use the site without rendering JavaScript:

- `public/llms.txt` is the curated entry point: what the SDK does, when to use it, and links to every page.
- `app/llms.txt/route.ts` generates the entry point from the docs source: what the SDK does, when to use it,
call examples, and links to every page.
- `app/llms-full.txt/route.ts` generates the complete docs with frontmatter removed and relative Markdown links
converted to absolute website URLs.
- `scripts/export-markdown.ts` runs after `next build` and writes a Markdown mirror for each documentation page
(`/docs/clients/` → `/docs/clients.md`, served as `text/markdown`) plus a concatenated `llms-full.txt`.
(`/docs/clients/` → `/docs/clients.md`, served as `text/markdown`).
- `app/sitemap.ts` emits `sitemap.xml`, and `public/robots.txt` explicitly allows AI crawlers.
- `/about/`, `/contact/`, and `/privacy/` are trust pages, and the homepage carries JSON-LD
(Organization with a contact point, WebSite, SoftwareApplication).
Expand All @@ -74,6 +77,7 @@ The build also exports files that let AI agents use the site without rendering J

`scripts/verify-export.ts` checks all of these after every build. GitHub Pages cannot do server-driven content
negotiation, so `Accept: text/markdown` requests still receive HTML; the `.md` mirrors are the Markdown path.
Run `bun run --cwd apps/docs test` to test the agent-document generators and JSON-LD helpers.
Build without `NEXT_PUBLIC_BASE_PATH` to serve at a domain root instead.

## Social cards
Expand Down
10 changes: 10 additions & 0 deletions apps/docs/app/llms-full.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { llmsFullText } from "@/lib/agent";
import { agentPagesWithMarkdown } from "@/lib/agent-pages";

export const dynamic = "force-static";

export async function GET() {
return new Response(llmsFullText(await agentPagesWithMarkdown()), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
8 changes: 8 additions & 0 deletions apps/docs/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { llmsText } from "@/lib/agent";
import { agentPages } from "@/lib/agent-pages";

export const dynamic = "force-static";

export function GET() {
return new Response(llmsText(agentPages()), { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}
3 changes: 3 additions & 0 deletions apps/docs/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { HomeLayout } from "fumadocs-ui/layouts/home";
import type { Metadata } from "next";
import Link from "next/link";
import { SiteFooter } from "@/components/site-footer";
import { baseOptions } from "@/lib/layout.shared";

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";

export const metadata: Metadata = { title: "Page not found", robots: { index: false } };

export default function NotFound() {
return (
<HomeLayout {...baseOptions()} className="min-h-dvh">
Expand Down
53 changes: 4 additions & 49 deletions apps/docs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,10 @@ import { CopyButton } from "@/components/copy-button";
import { InstallCommand } from "@/components/install-command";
import { MarketPlayground } from "@/components/market-playground";
import { SiteFooter } from "@/components/site-footer";
import { homeStructuredData, jsonLdScript } from "@/lib/agent";
import { baseOptions } from "@/lib/layout.shared";
import { siteUrl } from "@/lib/social";

const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://bloxwap.com/#organization",
name: "Bloxwap, Inc.",
url: "https://bloxwap.com",
sameAs: [
"https://github.com/bloxwap",
"https://x.com/bloxwap",
"https://t.me/bloxwap",
"https://discord.com/invite/cEfkcg6JHT",
"https://www.reddit.com/r/Bloxwap/",
],
contactPoint: {
"@type": "ContactPoint",
email: "support@bloxwap.com",
contactType: "customer support",
url: "https://bloxwap.github.io/hyperliquid/contact/",
},
},
{
"@type": "WebSite",
"@id": `${siteUrl.href}#website`,
url: siteUrl.href,
name: "Hyperliquid SDK · Bloxwap",
publisher: { "@id": "https://bloxwap.com/#organization" },
},
{
"@type": "SoftwareApplication",
name: "@bloxwap/hyperliquid",
description:
"A fast, fully typed TypeScript and JavaScript SDK for the Hyperliquid exchange API: market data, trading, signing, and real-time subscriptions over HTTP and WebSocket.",
applicationCategory: "DeveloperApplication",
operatingSystem: "Bun, Node.js, Browsers, React Native",
url: siteUrl.href,
downloadUrl: "https://www.npmjs.com/package/@bloxwap/hyperliquid",
codeRepository: "https://github.com/bloxwap/hyperliquid",
license: "https://opensource.org/licenses/MIT",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
author: { "@id": "https://bloxwap.com/#organization" },
},
],
};
const jsonLd = homeStructuredData();

const features = [
{
Expand Down Expand Up @@ -93,8 +48,8 @@ export default function Home() {
// min-h-dvh lets .landing grow into the slack, keeping the footer at the bottom of a tall window.
<HomeLayout {...baseOptions()} className="min-h-dvh">
<main className="landing">
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: JSON.stringify of a static constant is the Next.js-documented way to emit ld+json verbatim. */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: Static JSON-LD is escaped by jsonLdScript before embedding in HTML. */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(jsonLd) }} />
<div className="hero">
<div className="hero-copy">
<p className="eyebrow">
Expand Down
30 changes: 30 additions & 0 deletions apps/docs/lib/agent-pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { type AgentPage, agentMarkdown } from "./agent";
import { source } from "./source";

// Next runs route handlers from the docs workspace directory at build time.
const docsDir = resolve(process.cwd(), "content/docs");

export function agentPages(): AgentPage[] {
return source
.getPages()
.map((page) => ({ url: page.url, title: page.data.title, description: page.data.description }));
}

/** Every docs page with its Markdown source, read from the same files GitHub renders. */
export async function agentPagesWithMarkdown(): Promise<(AgentPage & { markdown: string })[]> {
return Promise.all(
source.getPages().map(async (page) => {
// Fumadocs sees README.md as index.md (see lib/source.ts); read the original file.
const sourcePath = page.path.replace(/(^|\/)index\.md$/, "$1README.md");
const raw = await readFile(resolve(docsDir, sourcePath), "utf8");
return {
url: page.url,
title: page.data.title,
description: page.data.description,
markdown: agentMarkdown(raw, sourcePath),
};
}),
);
}
128 changes: 128 additions & 0 deletions apps/docs/lib/agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test";
import sdkPackage from "../../../packages/hyperliquid/package.json";
import {
agentMarkdown,
homeStructuredData,
jsonLdScript,
llmsFullText,
llmsText,
organization,
pageUrl,
} from "./agent";

const site = "https://bloxwap.github.io/hyperliquid/";
const pages = [
{ url: "/docs/clients", title: "Clients", description: "Use the clients." },
{ url: "/docs", title: "Introduction" },
];

describe("pageUrl", () => {
test("publishes routes under the project base path with a trailing slash", () => {
expect(pageUrl("/")).toBe(site);
expect(pageUrl("/docs")).toBe(`${site}docs/`);
expect(pageUrl("/docs/guides/market-orders/")).toBe(`${site}docs/guides/market-orders/`);
});
});

describe("llmsText", () => {
const text = llmsText(pages);

test("follows the llms.txt layout: H1, blockquote summary, then H2 sections", () => {
const lines = text.split("\n");
expect(lines[0]).toBe("# Hyperliquid SDK (@bloxwap/hyperliquid)");
expect(lines[2]?.startsWith("> ")).toBe(true);
expect(text.match(/^# /gm)).toHaveLength(1);
expect(text).toContain("\n## When to use this\n");
expect(text).toContain("\n## How to call it\n");
expect(text).toContain("\n## Docs\n");
expect(text).toContain("\n## Optional\n");
});

test("links every docs page by its published URL, in a stable order", () => {
const docs = text.split("\n## Docs\n\n")[1]?.split("\n\n")[1];
expect(docs).toBe(`- [Introduction](${site}docs/)\n- [Clients](${site}docs/clients/): Use the clients.`);
});

test("points at the full text, sitemap, and company trust pages", () => {
for (const url of [
`${site}llms-full.txt`,
`${site}sitemap.xml`,
organization.about,
organization.contact,
organization.privacy,
]) {
expect(text).toContain(`](${url})`);
}
expect(organization.about).toBe(`${site}about/`);
expect(organization.contact).toBe(`${site}contact/`);
expect(organization.privacy).toBe(`${site}privacy/`);
});
});

describe("agentMarkdown", () => {
test("drops front matter", () => {
expect(agentMarkdown("---\ntitle: X\ndescription: Y\n---\n\n# X\n\nBody.\n", "clients.md")).toBe("# X\n\nBody.");
});

test("turns relative Markdown links into absolute website URLs", () => {
const raw = [
"[a](clients.md#info-endpoint)",
"[b](guides/README.md)",
"[c](../signing.md)",
"[d](README.md)",
"[e](https://example.com/x.md)",
"[f](#local)",
].join("\n");
expect(agentMarkdown(raw, "README.md").split("\n").slice(0, 2)).toEqual([
`[a](${site}docs/clients/#info-endpoint)`,
`[b](${site}docs/guides/)`,
]);
expect(agentMarkdown(raw, "guides/market-orders.md").split("\n")).toEqual([
`[a](${site}docs/guides/clients/#info-endpoint)`,
`[b](${site}docs/guides/guides/)`,
`[c](${site}docs/signing/)`,
`[d](${site}docs/guides/)`,
"[e](https://example.com/x.md)",
"[f](#local)",
]);
});
});

describe("llmsFullText", () => {
test("concatenates every page with its source URL and links back to llms.txt", () => {
const text = llmsFullText(pages.map((page) => ({ ...page, markdown: `# ${page.title}` })));
expect(text).toContain(`${site}llms.txt`);
expect(text).toContain(`<!-- Source: ${site}docs/clients/ -->\n\n# Clients`);
expect(text).toContain(`<!-- Source: ${site}docs/ -->\n\n# Introduction`);
});
});

describe("homeStructuredData", () => {
const graph = homeStructuredData()["@graph"] as Record<string, unknown>[];
const byType = (type: string) => graph.find((node) => node["@type"] === type);

test("describes the SDK as free, MIT-licensed software published by the organization", () => {
const software = byType("SoftwareApplication");
expect(software).toMatchObject({
name: "@bloxwap/hyperliquid",
url: site,
applicationCategory: "DeveloperApplication",
license: "https://opensource.org/licenses/MIT",
offers: { "@type": "Offer", price: "0" },
publisher: { "@id": organization.id },
});
expect(software?.softwareVersion).toBe(sdkPackage.version);
});

test("gives the organization a contact point and shares its @id with the root site", () => {
expect(byType("Organization")).toMatchObject({
"@id": "https://bloxwap.com/#organization",
contactPoint: { "@type": "ContactPoint", contactType: "customer support", email: organization.email },
});
});

test("serializes safely inside a script element", () => {
expect(jsonLdScript({ a: "</script><script>" })).toBe('{"a":"\\u003c/script>\\u003cscript>"}');
expect(JSON.parse(jsonLdScript(homeStructuredData()))).toEqual(homeStructuredData());
});
});
Loading
Loading