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
4 changes: 4 additions & 0 deletions apps/website/content/docs/ag-ui/api/inject-agent.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
description: injectAgent() returns the AG-UI agent configured by provideAgent() — Angular Signals for chat state, async methods for submit and tool calls.
---

# injectAgent()

`injectAgent()` retrieves the AG-UI agent from Angular's dependency injection container. Call it in an Angular injection context — typically as a component field initializer. The returned object exposes Angular Signals for reactive UI state and async methods for user actions.
Expand Down
4 changes: 4 additions & 0 deletions apps/website/content/docs/langgraph/api/inject-agent.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
description: injectAgent() connects an Angular app to a LangGraph Platform assistant — streaming messages, tool calls, and interrupts as Angular Signals.
---

# injectAgent()

`injectAgent()` is the LangGraph adapter for Angular. It connects to a LangGraph Platform assistant, consumes the LangGraph SDK event stream, and projects the result into the runtime-neutral `Agent` contract used by `@threadplane/chat`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
description: json-render renders a fixed spec. A2UI is an agent-to-UI protocol for surfaces that update over time and send user actions back. When to pick each.
---

# json-render vs A2UI

`@threadplane/render` and `@threadplane/a2ui` both render structured UI, but they solve different problems.
Expand Down
4 changes: 2 additions & 2 deletions apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
</div>
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden">
<MdxRenderer
source={doc.content}
source={doc.body}
library={library as LibraryId}
section={section}
slug={slug}
Expand Down Expand Up @@ -141,7 +141,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
<DocsPrevNext library={library as LibraryId} section={section} slug={slug} />
</div>
</div>
<DocsTOC headings={extractHeadings(doc.content)} />
<DocsTOC headings={extractHeadings(doc.body)} />
</div>
</div>
);
Expand Down
5 changes: 1 addition & 4 deletions apps/website/src/app/docs/choosing-an-adapter/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CodeGroup } from '../../../components/docs/mdx/CodeGroup';
import { Pre } from '../../../components/docs/mdx/CodeBlock';
import { mdxHeadingComponents } from '../../../components/docs/mdx/headings';
import { createPageMetadata } from '../../../lib/site-metadata';
import { stripFrontmatter } from '../../../lib/docs';

export const metadata = createPageMetadata({
title: 'Choosing an adapter — Threadplane',
Expand Down Expand Up @@ -59,10 +60,6 @@ function resolveContentFile(): string | null {
return null;
}

function stripFrontmatter(source: string): string {
return source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
}

export default function ChoosingAnAdapterPage() {
const filePath = resolveContentFile();
if (!filePath) notFound();
Expand Down
42 changes: 41 additions & 1 deletion apps/website/src/lib/docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getAllDocSlugs, getDocBySlug, getDocMetadata } from './docs';
import { getAllDocSlugs, getDocBySlug, getDocMetadata, stripFrontmatter } from './docs';
import { allDocsPages, docsConfig, findDocsPage, libraryIntroPath, specialDocsPages } from './docs-config';
import { getCanonicalUrl, getSitemapRoutes } from './site-metadata';

Expand Down Expand Up @@ -127,6 +127,46 @@ describe('website docs bindings', () => {
expect(duplicateDescriptions).toHaveLength(0);
});

// Both regressions below shipped together and hid each other: the description
// regex silently ignored the frontmatter, so the only visible symptom was the
// block rendering as Markdown — an <hr> plus a setext <h2> above the real <h1>.
it('prefers a frontmatter description when `description` is the last key', () => {
// Every real frontmatter block in content/docs/ ends on `description:`.
const metadata = getDocMetadata('chat', 'guides', 'custom-catalogs');

expect(metadata?.description).toBe(
'Compose custom component catalogs for generative UI using ViewRegistry composition.',
);
});

it('never leaks frontmatter keys into a derived description', () => {
for (const { library, section, slug } of getAllDocSlugs()) {
const description = getDocMetadata(library, section, slug)?.description ?? '';
expect(description, `/docs/${library}/${section}/${slug}`).not.toMatch(/^title:/);
}
});

it('exposes a render body with no frontmatter for every doc page', () => {
for (const { library, section, slug } of getAllDocSlugs()) {
const doc = getDocBySlug(library, section, slug);

expect(doc?.body.startsWith('---'), `/docs/${library}/${section}/${slug}`).toBe(false);
}
});

it('strips a frontmatter block before the body is handed to MDX', () => {
const source = '---\ntitle: X\ndescription: D.\n---\n\n# Heading\n\nBody.\n';

expect(stripFrontmatter(source)).toBe('# Heading\n\nBody.\n');
});

it('leaves a body that merely starts with a thematic break alone', () => {
// A leading `---` is only frontmatter when a closing fence follows it.
const source = '---\n\n# Heading\n';

expect(stripFrontmatter(source)).toBe(source);
});

it('includes every configured doc page in the sitemap routes', () => {
const sitemapRoutes = getSitemapRoutes();

Expand Down
39 changes: 35 additions & 4 deletions apps/website/src/lib/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,44 @@ export const DEFAULT_DOCS_DESCRIPTION = 'Threadplane documentation';

export interface ResolvedDoc {
page: DocsPage;
/** Raw file contents, frontmatter included — the description is read from it. */
content: string;
/** `content` with any frontmatter removed. This is what gets rendered. */
body: string;
title: string;
}

export type ResolvedDocMetadata = Metadata;

const FRONTMATTER_DESCRIPTION_PATTERN = /^---\s*\n[\s\S]*?\ndescription:\s*['"]?(?<description>[^'"\n]+)['"]?\s*\n[\s\S]*?\n---/;
/**
* A leading `---` fence and its closing partner. Matched as a whole block, then
* searched for keys — the previous single pattern spliced the two together and
* required a key to FOLLOW `description:`, so the last key in a block never
* matched. Every real block in content/docs/ ends on `description:`.
*/
const FRONTMATTER_BLOCK_PATTERN = /^---\s*\n(?<body>[\s\S]*?)\n---\s*(?:\n|$)/;

const FRONTMATTER_DESCRIPTION_PATTERN = /^description:\s*['"]?(?<description>[^'"\n]+?)['"]?\s*$/m;

/**
* Remove a frontmatter block so the rest can be handed to the MDX pipeline.
*
* `next-mdx-remote` does not strip frontmatter unless asked, and Markdown reads
* an unstripped block as an `<hr>` followed by a setext `<h2>` — a junk heading
* above the page's real `<h1>`, in its table of contents and heading anchors.
*
* A body that merely opens with a thematic break is left alone: a leading `---`
* is only frontmatter when a closing fence follows it.
*/
export function stripFrontmatter(source: string): string {
return source.replace(FRONTMATTER_BLOCK_PATTERN, '');
}

function readFrontmatterDescription(content: string): string | null {
const body = content.match(FRONTMATTER_BLOCK_PATTERN)?.groups?.body;
if (!body) return null;
return body.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description ?? null;
}

function normalizeDescription(description: string): string {
return description
Expand All @@ -33,8 +64,7 @@ function normalizeDescription(description: string): string {
}

function extractFirstParagraph(content: string): string | null {
const withoutFrontmatter = content.replace(/^---\s*\n[\s\S]*?\n---\s*/, '');
const withoutImports = withoutFrontmatter.replace(/^import\s.+$/gm, '');
const withoutImports = stripFrontmatter(content).replace(/^import\s.+$/gm, '');
const paragraphs = withoutImports.split(/\n{2,}/);

for (const paragraph of paragraphs) {
Expand All @@ -56,7 +86,7 @@ function extractFirstParagraph(content: string): string | null {
}

function getDocDescription(content: string, fallback: string): string {
const frontmatterDescription = content.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description;
const frontmatterDescription = readFrontmatterDescription(content);
if (frontmatterDescription) return normalizeDescription(frontmatterDescription);
return extractFirstParagraph(content) ?? fallback;
}
Expand All @@ -75,6 +105,7 @@ export function getDocBySlug(library: string, section: string, slug: string): Re
return {
page,
content,
body: stripFrontmatter(content),
title: titleMatch?.[1] ?? page.title,
};
}
Expand Down
Loading