diff --git a/.changeset/section-optional-links.md b/.changeset/section-optional-links.md new file mode 100644 index 0000000..4e04836 --- /dev/null +++ b/.changeset/section-optional-links.md @@ -0,0 +1,7 @@ +--- +'@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[]; } /**