From c46b62cf401579f9a0909d6ca8f8ae8abdbcce61 Mon Sep 17 00:00:00 2001 From: Taylor Reece Date: Fri, 14 Aug 2026 13:00:01 -0500 Subject: [PATCH 1/2] Include section-level optionalLinks in llms.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section-level `optionalLinks` were parsed and validated but never read by anything that produced output — only global `llmsTxt.optionalLinks` were rendered. - Carry section optionalLinks through ProcessedSection onto TreeNode - Render them in the section listing, after the section's documents - Seed sections (and their ancestors) that define only optional links so they are not skipped for having no matching routes - Keep sections with optional links or content-bearing descendants in filterValidSections instead of only checking docs.length - Stop reporting a links-only section as an empty-section quality issue, which would throw under onSectionError: 'throw' Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/section-optional-links.md | 5 +++ .../src/config/section-validator.ts | 3 +- .../src/organization/tree-builder.ts | 39 ++++++++++++++++++- .../src/organization/tree-renderer.ts | 7 ++++ .../src/types/core.ts | 8 +++- 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 .changeset/section-optional-links.md diff --git a/.changeset/section-optional-links.md b/.changeset/section-optional-links.md new file mode 100644 index 0000000..f6062bc --- /dev/null +++ b/.changeset/section-optional-links.md @@ -0,0 +1,5 @@ +--- +'@signalwire/docusaurus-plugin-llms-txt': patch +--- + +Fix section-level `optionalLinks` being dropped from llms.txt. They are now rendered inside their section (after the section's documents), and a section defining only optional links is no longer treated as empty. diff --git a/packages/docusaurus-plugin-llms-txt/src/config/section-validator.ts b/packages/docusaurus-plugin-llms-txt/src/config/section-validator.ts index acc3b22..82a2d37 100644 --- a/packages/docusaurus-plugin-llms-txt/src/config/section-validator.ts +++ b/packages/docusaurus-plugin-llms-txt/src/config/section-validator.ts @@ -157,6 +157,7 @@ interface ProcessedSection { description?: string; position?: number; docs: unknown[]; + optionalLinks?: readonly unknown[]; parentId?: string; } @@ -164,7 +165,7 @@ interface ProcessedSection { * Check if a processed section is empty */ function isEmpty(section: ProcessedSection): boolean { - return section.docs.length === 0; + return section.docs.length === 0 && !section.optionalLinks?.length; } /** diff --git a/packages/docusaurus-plugin-llms-txt/src/organization/tree-builder.ts b/packages/docusaurus-plugin-llms-txt/src/organization/tree-builder.ts index 8f34d9d..deca3cf 100644 --- a/packages/docusaurus-plugin-llms-txt/src/organization/tree-builder.ts +++ b/packages/docusaurus-plugin-llms-txt/src/organization/tree-builder.ts @@ -16,6 +16,7 @@ import type { PluginOptions, TreeNode, SectionDefinition, + OptionalLink, Logger, } from '../types'; @@ -76,6 +77,20 @@ export function buildDocumentTree( sectionGroups.get(finalSectionId)!.push(doc); } + // 2b. Ensure sections that only carry optional links still get created, + // along with their ancestors so the hierarchy can be rendered + for (const [sectionId, sectionDef] of sectionsMap) { + if (!sectionDef.optionalLinks?.length || sectionGroups.has(sectionId)) { + continue; + } + + let currentId: string | undefined = sectionId; + while (currentId && !sectionGroups.has(currentId)) { + sectionGroups.set(currentId, []); + currentId = sectionsMap.get(currentId)?.parentId; + } + } + // 3. Create processed sections const processedSections = new Map(); @@ -146,6 +161,7 @@ interface ProcessedSection { description?: string; position?: number; docs: DocInfo[]; + optionalLinks: readonly OptionalLink[]; parentId?: string; isAutoGenerated: boolean; autoSectionDepth: number; @@ -211,6 +227,7 @@ function createProcessedSection( description: sectionDef.description, position: sectionDef.position, docs: sortDocsByPath(docs), + optionalLinks: sectionDef.optionalLinks ?? [], parentId: sectionDef.parentId, isAutoGenerated, autoSectionDepth, @@ -228,10 +245,29 @@ function filterValidSections( return processedSections; // Include all sections, even empty ones } + // A section carries content when it has docs or optional links of its own + const hasOwnContent = (section: ProcessedSection): boolean => + section.docs.length > 0 || section.optionalLinks.length > 0; + + // Sections that only act as containers are kept when a descendant has + // content, otherwise the descendant would be dropped along with its parent + const childrenByParent = new Map(); + for (const section of processedSections.values()) { + if (section.parentId) { + const siblings = childrenByParent.get(section.parentId) ?? []; + siblings.push(section); + childrenByParent.set(section.parentId, siblings); + } + } + + const hasContent = (section: ProcessedSection): boolean => + hasOwnContent(section) || + (childrenByParent.get(section.id) ?? []).some(hasContent); + const validSections = new Map(); for (const [sectionId, section] of processedSections) { - if (section.docs.length > 0) { + if (hasContent(section)) { validSections.set(sectionId, section); } // Empty sections are excluded unless ignoring errors @@ -302,6 +338,7 @@ function buildHierarchicalTree( position: section.position, relPath, docs: section.docs, + optionalLinks: section.optionalLinks, subCategories, }; } diff --git a/packages/docusaurus-plugin-llms-txt/src/organization/tree-renderer.ts b/packages/docusaurus-plugin-llms-txt/src/organization/tree-renderer.ts index dad6388..832e21a 100644 --- a/packages/docusaurus-plugin-llms-txt/src/organization/tree-renderer.ts +++ b/packages/docusaurus-plugin-llms-txt/src/organization/tree-renderer.ts @@ -108,6 +108,13 @@ export function renderTreeAsMarkdown( md += `- [${d.title}](${formattedUrl})${descriptionText}\n`; }); + // Handle section-specific optional links (external URLs, listed as-is) + node.optionalLinks?.forEach((link) => { + const descriptionText = + enableDescriptions && link.description ? `: ${link.description}` : ''; + md += `- [${link.title}](${link.url})${descriptionText}\n`; + }); + // Process subcategories (already ordered by tree builder) if (node.subCategories.length) { node.subCategories.forEach((sub: TreeNode) => { diff --git a/packages/docusaurus-plugin-llms-txt/src/types/core.ts b/packages/docusaurus-plugin-llms-txt/src/types/core.ts index bcf4c83..54da83c 100644 --- a/packages/docusaurus-plugin-llms-txt/src/types/core.ts +++ b/packages/docusaurus-plugin-llms-txt/src/types/core.ts @@ -6,7 +6,12 @@ */ import type { ContentType } from '../constants'; -import type { PluginOptions, Logger, PluginInput } from './public'; +import type { + PluginOptions, + Logger, + PluginInput, + OptionalLink, +} from './public'; import type { Root } from 'hast'; import type { Options as RemarkGfmOptions } from 'remark-gfm'; import type { Options as RemarkStringifyOptions } from 'remark-stringify'; @@ -82,6 +87,7 @@ export interface TreeNode { readonly description?: string; readonly position?: number; readonly indexDoc?: DocInfo; + readonly optionalLinks?: readonly OptionalLink[]; } /** From f9be6ee8c2191e173058641ae0be63fb2d0018d9 Mon Sep 17 00:00:00 2001 From: Taylor Reece Date: Fri, 14 Aug 2026 13:07:58 -0500 Subject: [PATCH 2/2] Markdown formatting, apparently --- .changeset/section-optional-links.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/section-optional-links.md b/.changeset/section-optional-links.md index f6062bc..4e04836 100644 --- a/.changeset/section-optional-links.md +++ b/.changeset/section-optional-links.md @@ -2,4 +2,6 @@ '@signalwire/docusaurus-plugin-llms-txt': patch --- -Fix section-level `optionalLinks` being dropped from llms.txt. They are now rendered inside their section (after the section's documents), and a section defining only optional links is no longer treated as empty. +Fix section-level `optionalLinks` being dropped from llms.txt. They are now rendered inside their +section (after the section's documents), and a section defining only optional links is no longer +treated as empty.