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 .agents/skills/tsed/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: "tsed"
description: "Ts.ED documentation index — a compact overview of Ts.ED's docs. Consider consulting it, e.g. when using uncommon Ts.ED Framework"
---

See https://tsed.dev/llms.txt
9 changes: 2 additions & 7 deletions docs/.templates/page/page.ejs
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
---
symbol: <%- symbol.symbolName %>
module: "<%- symbol.module.moduleName %>"
type: <%- symbol.symbolType %>
title: <%- symbol.symbolName %> from <%- symbol.module.moduleName %>
description: api documentation of <%- symbol.symbolName %> from <%- symbol.module.moduleName %>
meta:
- name: keywords
description: api typescript node.js documentation <%- symbol.symbolName %> <%- symbol.symbolType %>
---

<script setup>
import ApiIcon from "@tsed/vitepress-theme/atoms/api-icon/ApiIcon.vue";
</script>

<div class="flex space-x-3">
<ApiIcon type="<%- symbol.symbolType %>" class="mt-3" />
<div>
Expand Down
42 changes: 39 additions & 3 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// @ts-ignore
import {apiAnchor} from "@tsed/vitepress-theme/markdown/api-anchor/api-anchor.js";
import {defineConfig} from "vitepress";
import pkg from "../../package.json";
import referenceSidebar from "../public/reference-sidebar.json";
import team from "../team.json";
import pkg from "../../package.json" with {type: "json"};
import referenceSidebar from "../public/reference-sidebar.json" with {type: "json"};
import team from "../team.json" with {type: "json"};
import llmstxt from "vitepress-plugin-llms";
import {apiLlmLinks} from "./plugins/apiLllmLinks.js";
import {buildLlmContentsPlugin} from "./plugins/buildLlmContents.js";

const Introduction = [
{
Expand Down Expand Up @@ -71,6 +74,39 @@ const Releases = [
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Ts.ED CLI a modern Node.js/Bun.js framework built with TypeScript to create interactive CLI applications",
vite: {
plugins: [
buildLlmContentsPlugin({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Generate the sidebar before loading the VitePress config

On the clean checkout used by .github/workflows/website.yml, docs/public/reference-sidebar.json is absent and gitignored, but the config eagerly imports it before Vite can reach this plugin's configResolved hook that creates it. After the stale root command is corrected, yarn docs:build will therefore fail while loading the config; generate the sidebar before launching VitePress or stop importing the generated file eagerly.

AGENTS.md reference: AGENTS.md:L9-L13

Useful? React with 👍 / 👎.

sections: [
{
source: "guide",
destination: "public/ai/guides",
label: "Guides"
},
{
source: "introduction",
destination: "public/ai/introduction",
label: "Introduction"
},
{
source: "api",
destination: "public/ai/api",
label: "API references"
}
],
sidebar: {
coreModulePattern: /cli-core|@tsed\/cli|cli-tasks|cli-prompts|cli-testing/,
coreModules: ["cli-core"]
}
}),
apiLlmLinks,
llmstxt({
ignoreFilesPerOutput: {
llmsTxt: ["api/**"]
}
})
]
},
lastUpdated: true,
description:
"Ts.ED offers a flexible and easy-to-learn structure designed to enhance the developer experience. It provides decorators, guidelines, and supports Node.js, Bun.js, Express.js, Koa.js, Fastify.js, CLI, and serverless architectures (e.g., AWS).",
Expand Down
13 changes: 13 additions & 0 deletions docs/.vitepress/plugins/apiLllmLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {getApiReferenceLinks} from "./utils/sidebar.js";

export const apiLlmLinks = {
enforce: "pre" as const,
name: "tsed-api-llm-links",
transform(content: string, id: string) {
if (!id.endsWith("/api.md")) {
return null;
}

return content.replace("<!-- API_LLM_LINKS -->", getApiReferenceLinks());
}
};
74 changes: 74 additions & 0 deletions docs/.vitepress/plugins/buildLlmContents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {join} from "node:path";

import {intro, log, outro, spinner} from "@clack/prompts";

import {copyFiles} from "./utils/copy-files.js";
import {buildReferenceSidebar, type ApiSidebarOptions} from "./utils/sidebar.js";

export interface LlmContentSection {
destination: string;
label: string;
source: string;
}

export interface BuildLlmContentsOptions {
docsRoot?: string;
sections?: LlmContentSection[];
sidebar?: ApiSidebarOptions;
}

const DEFAULT_DOCS_ROOT = join(import.meta.dirname, "..", "..");
/**
* Each entry describes a docs directory to copy into /public/ai.
* Markdown is normalized (remark), snippet directives are inlined,
* and @@Symbol@@ tokens are rewritten to /ai/api links.
*/
const DEFAULT_DOC_SECTIONS: LlmContentSection[] = [];
let buildTask: Promise<void> | undefined;

export async function buildLlmContents({
docsRoot = DEFAULT_DOCS_ROOT,
sections = DEFAULT_DOC_SECTIONS,
sidebar
}: BuildLlmContentsOptions = {}) {
intro("Building LLM references");

try {
for (const section of sections) {
const completed = await copyFiles({
cwd: docsRoot,
src: section.source,
dest: section.destination,
label: section.label
});

if (!completed) {
log.warn(`${section.label} copy skipped`);
}
}

const sidebarStep = spinner();
sidebarStep.start("Generating API sidebar");
await buildReferenceSidebar(docsRoot, sidebar);
sidebarStep.stop("Sidebar generated");

outro("LLM references ready");
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
outro("LLM references build failed");
throw error;
}
}

// Orchestration only; implementation lives in ./llm/*

export function buildLlmContentsPlugin(options: BuildLlmContentsOptions = {}) {
return {
enforce: "pre" as const,
name: "tsed-build-llm-contents",
async configResolved() {
buildTask ??= buildLlmContents(options);
await buildTask;
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,22 @@ import {transformMarkdown} from "./markdown.js";

const {copy, ensureDir, pathExists, readFile, remove, writeFile} = fsExtra;

export async function copyFiles({cwd, src, dest, label}) {
export interface CopyFilesOptions {
cwd: string;
dest: string;
label: string;
src: string;
}

interface CopyFileOptions {
cwd: string;
destinationRoot: string;
progressLogger: Pick<ReturnType<typeof progress>, "advance" | "stop">;
relativePath: string;
sourceRoot: string;
}

export async function copyFiles({cwd, src, dest, label}: CopyFilesOptions) {
const sourceDir = join(cwd, src);
const destinationDir = join(cwd, dest);

Expand Down Expand Up @@ -50,7 +65,7 @@ export async function copyFiles({cwd, src, dest, label}) {
return true;
}

async function copyFile({cwd, sourceRoot, destinationRoot, relativePath, progressLogger}) {
async function copyFile({cwd, sourceRoot, destinationRoot, relativePath, progressLogger}: CopyFileOptions) {
const sourcePath = join(sourceRoot, relativePath);
const destinationPath = join(destinationRoot, relativePath);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,40 @@ const {readFile} = fsExtra;
const markdownProcessor = unified().use(remarkParse).use(remarkStringify, {fences: true, bullet: "-"}).use(remarkCleanApiMarkdown);
const INLINE_SNIPPET_RE = /^<<<\s+@\/([^\s]+?)(?:\s+\[(.+?)\])?\s*$/gm;
const SYMBOL_TOKEN_RE = /@@([A-Za-z0-9_.-]+)@@/g;
const symbolIndexCache = new Map();

export async function transformMarkdown(content, options = {}) {
interface ApiData {
modules?: Record<string, ApiModule>;
}

interface ApiModule {
symbols?: ApiSymbol[];
}

interface ApiSymbol {
path?: string;
symbolName: string;
}

interface ExampleBlock {
end: number;
label: string;
original: string;
relativePath: string;
start: number;
}

interface HeadingInfo {
module: string;
title: string;
}

const symbolIndexCache = new Map<string, Map<string, ApiSymbol>>();

export interface TransformMarkdownOptions {
docsRoot?: string;
}

export async function transformMarkdown(content: string, options: TransformMarkdownOptions = {}) {
const {docsRoot} = options;
let nextContent = content;

Expand All @@ -26,8 +57,8 @@ export async function transformMarkdown(content, options = {}) {
return frontmatter ? `${frontmatter}\n${cleanedBody}` : cleanedBody;
}

async function inlineExampleBlocks(content, docsRoot) {
const matches = [];
async function inlineExampleBlocks(content: string, docsRoot: string) {
const matches: ExampleBlock[] = [];
let match;

while ((match = INLINE_SNIPPET_RE.exec(content)) !== null) {
Expand Down Expand Up @@ -57,9 +88,9 @@ async function inlineExampleBlocks(content, docsRoot) {
return result;
}

async function replaceSymbolLinks(content, docsRoot) {
async function replaceSymbolLinks(content: string, docsRoot: string) {
const index = await loadSymbolIndex(docsRoot);
return content.replace(SYMBOL_TOKEN_RE, (match, symbolName) => {
return content.replace(SYMBOL_TOKEN_RE, (match: string, symbolName: string) => {
const entry = index.get(symbolName);

if (!entry) {
Expand All @@ -71,17 +102,18 @@ async function replaceSymbolLinks(content, docsRoot) {
});
}

async function loadSymbolIndex(docsRoot) {
if (symbolIndexCache.has(docsRoot)) {
return symbolIndexCache.get(docsRoot);
async function loadSymbolIndex(docsRoot: string) {
const cachedIndex = symbolIndexCache.get(docsRoot);
if (cachedIndex) {
return cachedIndex;
}

const apiPath = join(docsRoot, "public/api.json");
const data = JSON.parse(await readFile(apiPath, "utf8"));
const map = new Map();
const data = JSON.parse(await readFile(apiPath, "utf8")) as ApiData;
const map = new Map<string, ApiSymbol>();

Object.values(data.modules ?? {}).forEach((module) => {
module.symbols?.forEach((symbol) => {
Object.values(data.modules ?? {}).forEach((module: ApiModule) => {
module.symbols?.forEach((symbol: ApiSymbol) => {
if (symbol.symbolName && symbol.path) {
map.set(symbol.symbolName, symbol);
}
Expand All @@ -92,7 +124,7 @@ async function loadSymbolIndex(docsRoot) {
return map;
}

async function loadSnippetBlock(entry, docsRoot) {
async function loadSnippetBlock(entry: ExampleBlock, docsRoot: string) {
const absolutePath = join(docsRoot, entry.relativePath);

try {
Expand All @@ -101,20 +133,22 @@ async function loadSnippetBlock(entry, docsRoot) {
const labelSuffix = entry.label ? ` [${entry.label}]` : "";
return `\`\`\`${language}${labelSuffix}\n${code.trimEnd()}\n\`\`\``;
} catch (error) {
console.warn(`[build-llm-contents] Unable to inline snippet ${absolutePath}: ${error.message}`);
console.warn(
`[build-llm-contents] Unable to inline snippet ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
);
return entry.original;
}
}

function getLanguageFromExtension(extension) {
function getLanguageFromExtension(extension: string) {
return extension ? extension.replace(/^\./, "") : "";
}

function remarkCleanApiMarkdown() {
return (tree) => {
let headingInfo;
return (tree: any) => {
let headingInfo: HeadingInfo | undefined;

tree.children = tree.children.filter((node) => {
tree.children = tree.children.filter((node: any) => {
if (node.type === "html") {
const trimmed = node.value.trim();

Expand Down Expand Up @@ -143,14 +177,14 @@ function remarkCleanApiMarkdown() {
]
};

const insertIndex = tree.children.findIndex((node) => node.type !== "yaml");
const insertIndex = tree.children.findIndex((node: any) => node.type !== "yaml");
const targetIndex = insertIndex === -1 ? tree.children.length : insertIndex;
tree.children.splice(targetIndex, 0, headingNode);
}
};
}

function extractFrontmatter(content) {
function extractFrontmatter(content: string) {
if (!content.startsWith("---")) {
return {frontmatter: "", body: content};
}
Expand All @@ -167,7 +201,7 @@ function extractFrontmatter(content) {
return {frontmatter, body};
}

function extractHeading(value) {
function extractHeading(value: string): HeadingInfo {
const titleMatch = value.match(/<h1>([\s\S]*?)<\/h1>/i);
const moduleMatch = value.match(/<div class="module-name">([\s\S]*?)<\/div>/i);

Expand Down
Loading
Loading