Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
7 changes: 7 additions & 0 deletions .chronus/changes/gen-llmstxt-2025-8-8-9-27-39.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/tspd"
---

Adds `llmstxt` frontmatter to generated reference docs to enable inclusion in llms.txt. Opt-in: specify `--llmstxt` to enable
15 changes: 15 additions & 0 deletions .chronus/changes/gen-llmstxt-2025-8-8-9-28-15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
changeKind: internal
packages:
- "@typespec/events"
- "@typespec/http"
- "@typespec/json-schema"
- "@typespec/openapi"
- "@typespec/openapi3"
- "@typespec/protobuf"
- "@typespec/rest"
- "@typespec/streams"
- "@typespec/versioning"
---

Updated doc generation to generate `llmstxt` frontmatter
2 changes: 2 additions & 0 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ words:
- LINUXOS
- LINUXVMIMAGE
- ljust
- llms
- llmstxt
- lmazuel
- lropaging
- lstrip
Expand Down
4 changes: 3 additions & 1 deletion packages/astro-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"./components/*": "./src/components/*",
"./utils/*": "./src/utils/*.ts",
"./css/*": "./src/css/*",
"./expressive-code/*": "./dist/expressive-code/*.js"
"./expressive-code/*": "./dist/expressive-code/*.js",
"./llmstxt": "./src/llmstxt/index.ts",
"./llmstxt/schema": "./src/llmstxt/schema.ts"
},
"files": [
"src",
Expand Down
90 changes: 90 additions & 0 deletions packages/astro-utils/src/llmstxt/generators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { mergeSiteWithPath, type DocEntry, type LlmsTxtAsJson } from "./index";

/**
* Generates the Markdown path following the llms.tst specification.
* @param docId The document ID from Astro content collections.
*/
export function generateMarkdownPath(docId: string): string {
// If the final path fragment does not include a file extension, use `index.html.md`
if (docId.endsWith("/")) {
return `${docId}index.html.md`;
}

const finalPathFragment = docId.split("/").pop() ?? "";
if (!finalPathFragment.includes(".")) {
return `${docId}/index.html.md`;
}

return `${docId}.md`;
}

/**
* Generates the LLMs text following the llms.tst specification.
* @param llmsData The pre-processed LLMs JSON data.
* @see `processDocsForLlmsTxt`
*/
export function generateLlmstxt(llmsData: LlmsTxtAsJson): string {
const contents: string[] = [];
contents.push(`# ${llmsData.title}`);
contents.push(`> ${llmsData.description}`);

for (const [name, topic] of Object.entries(llmsData.topics)) {
if (!topic.length) continue;
const section: string[] = [];
section.push(`## ${name}\n`);
for (const { title, url, description } of topic) {
section.push(`- [${title}](${url}): ${description}`);
}
contents.push(section.join("\n"));
}
return contents.join("\n\n");
}

/**
* Generates the full LLMs text - the combined markdown documentation referenced from an llms.txt.
* @param title The title for the LLMs text file.
* @param docs The collection of documentation entries to include.
*/
export function generateLlmstxtFull(title: string, docs: DocEntry[]): string {
const contents: string[] = [];
contents.push(`# ${title}`);

for (const doc of docs) {
if (!doc.body) continue;

const docTitle = doc.data.title;
const docDescription = doc.data.description ?? "";
contents.push(`# ${docTitle}`);
if (docDescription) contents.push(docDescription);
contents.push(doc.body);
}

return contents.join("\n\n");
}

export type GenerateLlmsJsonTopicDetails = {
id: string;
description: string;
pathPrefix: string;
};

export type LlmsJson = {
topic: string;
description: string;
contentUrl: string;
}[];

/**
* Generates the `llms.json` version of `llms.txt`.
* This is meant for easier consumption by our tools.
*/
export function generateLlmsJson(
topicDetails: GenerateLlmsJsonTopicDetails[],
siteHref: string,
): LlmsJson {
return topicDetails.map(({ id, description, pathPrefix }) => ({
topic: id,
description,
contentUrl: mergeSiteWithPath(siteHref, pathPrefix, "llms-full.txt"),
}));
}
137 changes: 137 additions & 0 deletions packages/astro-utils/src/llmstxt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { z } from "astro:content";
import { generateMarkdownPath } from "./generators";
import type { llmstxtSchema } from "./schema";

export * from "./generators";
export * from "./routes";
export * from "./topics";
export interface DocEntry {
id: string;
data: {
title: string;
description?: string;
llmstxt?: z.infer<typeof llmstxtSchema>;
};
body?: string;
}

export interface LlmsTxtAsJson {
title: string;
description: string;
topics: Record<
string,
{
title: string;
description?: string;
url: string;
}[]
>;
}

export interface ProcessDocsProps {
/**
* Title for the LLMs text file.
*/
title: string;
/**
* Description for the LLMs text file.
*/
description: string;
/**
* The site URL, used to generate full URLs for documentation entries.
*/
site?: URL;
/**
* The collection of documentation entries to process.
* Each entry must include a valid LLMs text schema.
* See `import("astro:content").getCollection`
*/
docs: DocEntry[];
/**
* Name of the llmstxt section and the pathPrefix to match against doc IDs.
* If a doc matches multiple pathPrefixes, it will be assigned to the first matching section.
*/
llmsSections: { name: string; pathPrefix: string }[];
}

/**
* Processes astro content collection docs and metadata for easy `llms.txt` generation.
*/
export async function processDocsForLlmsTxt({
title,
description,
site,
docs,
llmsSections,
}: ProcessDocsProps) {
const sections = organizeDocsIntoSections(docs, llmsSections);
const result: LlmsTxtAsJson = { title, description, topics: {} };

const siteHref = site?.href ?? "";
for (const [sectionName, sectionDocs] of Object.entries(sections)) {
if (sectionDocs.length === 0) continue;

const topic = sectionName;
const topics = sectionDocs.map((doc) => {
const title = doc.data.title;
const desc = doc.data.description ?? "";
const path = generateMarkdownPath(doc.id);
const url = mergeSiteWithPath(siteHref, path);
return { title, description: desc, url };
});

result.topics[topic] = topics;
}

return result;
}

function organizeDocsIntoSections(
docs: DocEntry[],
llmsSections: ProcessDocsProps["llmsSections"],
) {
docs.sort((a, b) => (a.id > b.id ? 1 : -1));
const seenDocs = new Set<DocEntry>();
const sections: Record<string, DocEntry[]> = {};

for (const { name, pathPrefix } of llmsSections) {
sections[name] = docs.filter((doc) => {
if (seenDocs.has(doc)) return false;
if (doc.id.startsWith(pathPrefix)) {
seenDocs.add(doc);
return true;
}
return false;
});
}

return sections;
}

/**
* Merges a site URL with path parts.
* Used when needing to create full URLs when working with astro content collections.
* @param siteHref The base URL of the site.
* @param pathParts The path parts to merge with the site URL.
* @returns The merged URL.
*/
export function mergeSiteWithPath(siteHref: string, ...pathParts: string[]): string {
let result = siteHref;

for (const part of pathParts) {
if (!part) continue; // Skip empty parts

const resultTrailingSlash = result.endsWith("/");
const partLeadingSlash = part.startsWith("/");

if (resultTrailingSlash && partLeadingSlash) {
result = `${result}${part.slice(1)}`;
} else if (!resultTrailingSlash && !partLeadingSlash) {
result = `${result}/${part}`;
} else {
result = `${result}${part}`;
}
}

return result;
}
54 changes: 54 additions & 0 deletions packages/astro-utils/src/llmstxt/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { APIRoute } from "astro";
import {
generateLlmstxt,
generateLlmstxtFull,
processDocsForLlmsTxt,
type DocEntry,
type TopicProps,
} from "./index";

export type RouteParams = { path: string; llms_type: "llms" | "llms-full" };
export type RouteProps = Pick<Required<TopicProps>, "title" | "description" | "docs">;

export const spreadLlmsTxtRoute: APIRoute<RouteProps, RouteParams> = async ({
props,
params,
site,
}) => {
const { title, docs, description } = props;
const { llms_type } = params;

if (llms_type === "llms") {
const llmsData = await processDocsForLlmsTxt({
title,
description,
docs,
// Use blank pathPrefix to include all docs in the llms.txt
llmsSections: [{ name: "Docs", pathPrefix: "" }],
site,
});

const llmstxt = generateLlmstxt(llmsData);
return new Response(llmstxt, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
},
});
} else {
const llmstxt = generateLlmstxtFull(description, docs);
return new Response(llmstxt, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
},
});
}
};

export const markdownRoute: APIRoute<{ doc: DocEntry }> = async ({ props }) => {
const { doc } = props;
return new Response(doc.body ?? "", {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
},
});
};
3 changes: 3 additions & 0 deletions packages/astro-utils/src/llmstxt/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { z } from "astro:content";

export const llmstxtSchema = z.boolean().optional()
34 changes: 34 additions & 0 deletions packages/astro-utils/src/llmstxt/topics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { DocEntry } from ".";

export interface TopicProps {
title: string;
description: string;
pathPrefix: string;
docs: DocEntry[];
id: string;
}

/**
*
*/
export function populateTopicDocs(
topics: Omit<TopicProps, "docs">[],
docs: DocEntry[],
): TopicProps[] {
docs.sort((a, b) => (a.id > b.id ? 1 : -1));
const seenDocs = new Set<DocEntry>();

return topics.map((topic) => {
return {
...topic,
docs: docs.filter((doc) => {
if (seenDocs.has(doc)) return false;
if (doc.id.startsWith(topic.pathPrefix)) {
seenDocs.add(doc);
return true;
}
return false;
}),
};
});
}
2 changes: 1 addition & 1 deletion packages/events/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"test-official": "vitest run --coverage --reporter=junit --reporter=default --no-file-parallelism",
"lint": "eslint . --ext .ts --max-warnings=0",
"lint:fix": "eslint . --fix --ext .ts",
"regen-docs": "tspd doc . --enable-experimental --output-dir ../../website/src/content/docs/docs/libraries/events/reference"
"regen-docs": "tspd doc . --enable-experimental --llmstxt --output-dir ../../website/src/content/docs/docs/libraries/events/reference"
},
"files": [
"lib/*.tsp",
Expand Down
2 changes: 1 addition & 1 deletion packages/http/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"test:ci": "vitest run --coverage --reporter=junit --reporter=default",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"regen-docs": "tspd doc . --enable-experimental --typekits --output-dir ../../website/src/content/docs/docs/libraries/http/reference"
"regen-docs": "tspd doc . --enable-experimental --typekits --llmstxt --output-dir ../../website/src/content/docs/docs/libraries/http/reference"
},
"files": [
"lib/**/*.tsp",
Expand Down
2 changes: 1 addition & 1 deletion packages/json-schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"test:ci": "vitest run --coverage --reporter=junit --reporter=default",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"regen-docs": "tspd doc . --enable-experimental --output-dir ../../website/src/content/docs/docs/emitters/json-schema/reference",
"regen-docs": "tspd doc . --enable-experimental --llmstxt --output-dir ../../website/src/content/docs/docs/emitters/json-schema/reference",
"api-extractor": "api-extractor run --local --verbose"
},
"files": [
Expand Down
Loading
Loading