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
24 changes: 24 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,30 @@ jobs:
e2e:
uses: ./.github/workflows/e2e.yml

search-api:
runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: production
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

- name: setup bun
uses: oven-sh/setup-bun@v2

- name: install dependencies
run: bun install --frozen-lockfile

- name: deploy search api to cloudflare workers
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
packageManager: bun
workingDirectory: packages/search-api
command: deploy

website:
uses: ./.github/workflows/website.yml
with:
Expand Down
20 changes: 19 additions & 1 deletion .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ jobs:
env:
NODE_OPTIONS: "--max-old-space-size=8192"

- name: export search indexes
run: bun run search:export
working-directory: packages/documentation

# TODO: add R2 lifecycle cleanup/retention for per-commit preview search indexes.
- name: upload search index to r2
working-directory: packages/documentation
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
SEARCH_INDEX_KEY: ${{ github.sha }}
run: |
aws s3 cp ./.output/search/latest.json \
"s3://hive-docs-search-index/search/${SEARCH_INDEX_KEY}.json" \
--endpoint-url "https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"

- name: deploy to cloudflare workers
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
id: deploy
Expand All @@ -37,7 +55,7 @@ jobs:
packageManager: bun
workingDirectory: packages/documentation
command: |
${{ github.ref == 'refs/heads/main' && 'deploy' || 'versions upload' }}
${{ github.ref == 'refs/heads/main' && format('deploy --var SEARCH_INDEX_KEY:{0}', github.sha) || format('versions upload --var SEARCH_INDEX_KEY:{0}', github.sha) }}
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

- name: find deployment comment
Expand Down
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/documentation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"scripts": {
"dev": "vite dev",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"search:export": "bun --bun ./tools/export-search-indexes.ts ./.output/search/latest.json",
"check-seo": "bun ./tools/check-seo.ts",
"start": "bun .output/server/index.mjs",
"typecheck": "bun --bun fumadocs-mdx && bun --bun tsc --noEmit",
Expand Down
155 changes: 155 additions & 0 deletions packages/documentation/src/lib/search-indexes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { StructuredData } from "fumadocs-core/mdx-plugins/remark-structure";
import type { AdvancedIndex } from "fumadocs-core/search/server";

import { CHANGELOG_PAGE_URL } from "@/lib/deployment-changelog";
import { pathToSlug } from "@/lib/path-to-slug";
import { getSource } from "@/lib/source";
import { structure } from "fumadocs-core/mdx-plugins/remark-structure";
import { findPath } from "fumadocs-core/page-tree";

function getDocsBreadcrumbs(
source: Awaited<ReturnType<typeof getSource>>,
pageUrl: string,
): string[] | undefined {
const pageTree = source.getPageTree();
const path = findPath(
pageTree.children,
(node) => node.type === "page" && node.url === pageUrl,
);
if (!path) return undefined;
path.pop();
const breadcrumbs: string[] = [];
if (typeof pageTree.name === "string" && pageTree.name.length > 0) {
breadcrumbs.push(pageTree.name);
}
for (const segment of path) {
if (typeof segment.name === "string" && segment.name.length > 0) {
breadcrumbs.push(segment.name);
}
}
return breadcrumbs;
}

type DataWithStructuredData = {
structuredData: StructuredData;
};

type DataWithLoader = {
load(): Promise<DataWithStructuredData>;
};

async function resolveStructuredData(data: unknown): Promise<StructuredData> {
if (
typeof data === "object" &&
data !== null &&
"structuredData" in data &&
"load" in data === false
) {
return (data as DataWithStructuredData).structuredData;
}

if (
typeof data === "object" &&
data !== null &&
"load" in data &&
typeof (data as DataWithLoader).load === "function"
) {
const loaded = await (data as DataWithLoader).load();
return loaded.structuredData;
}

throw new Error("Cannot resolve structuredData from page");
}

async function getChangelogStructuredData(): Promise<StructuredData> {
try {
const snapshotModule =
(await import("virtual:deployment-changelog-snapshot")) as {
deploymentChangelogSnapshot?: string;
};

if (!snapshotModule.deploymentChangelogSnapshot) {
return { contents: [], headings: [] };
}

return structure(snapshotModule.deploymentChangelogSnapshot);
} catch {
return { contents: [], headings: [] };
}
}

export async function buildIndexes(): Promise<AdvancedIndex[]> {
const source = await getSource();
const { blog, caseStudies, productUpdates } =
await import("fumadocs-mdx:collections/server");

const changelogStructuredData = getChangelogStructuredData();

const docsIndexes = await Promise.all(
source.getPages().map(async (page) => ({
breadcrumbs: getDocsBreadcrumbs(source, page.url),
description: page.data.description,
id: page.url,
structuredData:
page.url === CHANGELOG_PAGE_URL
? await changelogStructuredData
: await resolveStructuredData(page.data),
title: page.data.title ?? page.url,
url: page.url,
})),
);

const caseStudyIndexes = await Promise.all(
caseStudies.map(async (entry) => {
const { structuredData } = await entry.load();
const slug = pathToSlug(entry.info.path);
return {
breadcrumbs: ["Case Studies"],
description: entry.excerpt,
id: `/case-studies/${slug}`,
structuredData,
title: entry.title,
url: `/case-studies/${slug}`,
};
}),
);

const productUpdateIndexes = await Promise.all(
productUpdates.map(async (entry) => {
const { structuredData } = await entry.load();
const slug = pathToSlug(entry.info.path);
return {
breadcrumbs: ["Product Updates"],
description: entry.description,
id: `/product-updates/${slug}`,
structuredData,
title: entry.title ?? slug,
url: `/product-updates/${slug}`,
};
}),
);

const blogIndexes = await Promise.all(
blog.map(async (entry) => {
const { structuredData } = await entry.load();
const slug = entry.info.path
.replace(/\.mdx?$/, "")
.replace(/\/index$/, "");
return {
breadcrumbs: ["Blog"],
description: entry.description,
id: `/blog/${slug}`,
structuredData,
title: entry.title ?? slug,
url: `/blog/${slug}`,
};
}),
);

return [
...docsIndexes,
...caseStudyIndexes,
...productUpdateIndexes,
...blogIndexes,
];
}
1 change: 1 addition & 0 deletions packages/documentation/src/lib/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type MetaTag = {
};

type LinkTag = {
as?: string;
href: string;
rel: string;
};
Expand Down
21 changes: 21 additions & 0 deletions packages/documentation/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Route as LlmsFullDottxtRouteImport } from './routes/llms-full[.]txt'
import { Route as FeedDotxmlRouteImport } from './routes/feed[.]xml'
import { Route as LandingRouteImport } from './routes/_landing'
import { Route as DocsSplatRouteImport } from './routes/docs/$'
import { Route as ApiSearchDotjsonRouteImport } from './routes/api/search[.]json'
import { Route as ApiSearchRouteImport } from './routes/api/search'
import { Route as LandingLightOnlyRouteImport } from './routes/_landing/_light-only'
import { Route as LandingProductUpdatesIndexRouteImport } from './routes/_landing/product-updates/index'
Expand Down Expand Up @@ -57,6 +58,11 @@ const DocsSplatRoute = DocsSplatRouteImport.update({
path: '/docs/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSearchDotjsonRoute = ApiSearchDotjsonRouteImport.update({
id: '/api/search.json',
path: '/api/search.json',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSearchRoute = ApiSearchRouteImport.update({
id: '/api/search',
path: '/api/search',
Expand Down Expand Up @@ -158,6 +164,7 @@ export interface FileRoutesByFullPath {
'/llms-full.txt': typeof LlmsFullDottxtRoute
'/llms.txt': typeof LlmsDottxtRoute
'/api/search': typeof ApiSearchRoute
'/api/search.json': typeof ApiSearchDotjsonRoute
'/docs/$': typeof DocsSplatRoute
'/ecosystem': typeof LandingLightOnlyEcosystemRoute
'/gateway': typeof LandingLightOnlyGatewayRoute
Expand All @@ -181,6 +188,7 @@ export interface FileRoutesByTo {
'/llms-full.txt': typeof LlmsFullDottxtRoute
'/llms.txt': typeof LlmsDottxtRoute
'/api/search': typeof ApiSearchRoute
'/api/search.json': typeof ApiSearchDotjsonRoute
'/docs/$': typeof DocsSplatRoute
'/ecosystem': typeof LandingLightOnlyEcosystemRoute
'/gateway': typeof LandingLightOnlyGatewayRoute
Expand All @@ -207,6 +215,7 @@ export interface FileRoutesById {
'/llms.txt': typeof LlmsDottxtRoute
'/_landing/_light-only': typeof LandingLightOnlyRouteWithChildren
'/api/search': typeof ApiSearchRoute
'/api/search.json': typeof ApiSearchDotjsonRoute
'/docs/$': typeof DocsSplatRoute
'/_landing/_light-only/ecosystem': typeof LandingLightOnlyEcosystemRoute
'/_landing/_light-only/gateway': typeof LandingLightOnlyGatewayRoute
Expand All @@ -232,6 +241,7 @@ export interface FileRouteTypes {
| '/llms-full.txt'
| '/llms.txt'
| '/api/search'
| '/api/search.json'
| '/docs/$'
| '/ecosystem'
| '/gateway'
Expand All @@ -255,6 +265,7 @@ export interface FileRouteTypes {
| '/llms-full.txt'
| '/llms.txt'
| '/api/search'
| '/api/search.json'
| '/docs/$'
| '/ecosystem'
| '/gateway'
Expand All @@ -280,6 +291,7 @@ export interface FileRouteTypes {
| '/llms.txt'
| '/_landing/_light-only'
| '/api/search'
| '/api/search.json'
| '/docs/$'
| '/_landing/_light-only/ecosystem'
| '/_landing/_light-only/gateway'
Expand All @@ -305,6 +317,7 @@ export interface RootRouteChildren {
LlmsFullDottxtRoute: typeof LlmsFullDottxtRoute
LlmsDottxtRoute: typeof LlmsDottxtRoute
ApiSearchRoute: typeof ApiSearchRoute
ApiSearchDotjsonRoute: typeof ApiSearchDotjsonRoute
DocsSplatRoute: typeof DocsSplatRoute
LlmsDotmdxDocsSplatRoute: typeof LlmsDotmdxDocsSplatRoute
}
Expand Down Expand Up @@ -346,6 +359,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/search.json': {
id: '/api/search.json'
path: '/api/search.json'
fullPath: '/api/search.json'
preLoaderRoute: typeof ApiSearchDotjsonRouteImport
parentRoute: typeof rootRouteImport
}
'/api/search': {
id: '/api/search'
path: '/api/search'
Expand Down Expand Up @@ -531,6 +551,7 @@ const rootRouteChildren: RootRouteChildren = {
LlmsFullDottxtRoute: LlmsFullDottxtRoute,
LlmsDottxtRoute: LlmsDottxtRoute,
ApiSearchRoute: ApiSearchRoute,
ApiSearchDotjsonRoute: ApiSearchDotjsonRoute,
DocsSplatRoute: DocsSplatRoute,
LlmsDotmdxDocsSplatRoute: LlmsDotmdxDocsSplatRoute,
}
Expand Down
9 changes: 8 additions & 1 deletion packages/documentation/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export const Route = createRootRoute({
component: RootComponent,
errorComponent: RootErrorComponent,
head: seo({
links: [
{
href: withBasePath("/api/search"),
rel: "prefetch",
},
],
meta: [
{
// eslint-disable-next-line unicorn/text-encoding-identifier-case
Expand Down Expand Up @@ -87,7 +93,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
search={{
options: {
api: withBasePath("/api/search"),
type: "static",
delayMs: 500,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default was so low that when I typed "router" it ended end with ro, rout and router http calls, first two were not even cancelled... :D

type: "fetch",
},
}}
theme={
Expand Down
Loading
Loading