Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/section-optional-links.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,15 @@ interface ProcessedSection {
description?: string;
position?: number;
docs: unknown[];
optionalLinks?: readonly unknown[];
parentId?: string;
}

/**
* 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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
PluginOptions,
TreeNode,
SectionDefinition,
OptionalLink,
Logger,
} from '../types';

Expand Down Expand Up @@ -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<string, ProcessedSection>();

Expand Down Expand Up @@ -146,6 +161,7 @@ interface ProcessedSection {
description?: string;
position?: number;
docs: DocInfo[];
optionalLinks: readonly OptionalLink[];
parentId?: string;
isAutoGenerated: boolean;
autoSectionDepth: number;
Expand Down Expand Up @@ -211,6 +227,7 @@ function createProcessedSection(
description: sectionDef.description,
position: sectionDef.position,
docs: sortDocsByPath(docs),
optionalLinks: sectionDef.optionalLinks ?? [],
parentId: sectionDef.parentId,
isAutoGenerated,
autoSectionDepth,
Expand All @@ -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<string, ProcessedSection[]>();
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<string, ProcessedSection>();

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
Expand Down Expand Up @@ -302,6 +338,7 @@ function buildHierarchicalTree(
position: section.position,
relPath,
docs: section.docs,
optionalLinks: section.optionalLinks,
subCategories,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
8 changes: 7 additions & 1 deletion packages/docusaurus-plugin-llms-txt/src/types/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -82,6 +87,7 @@ export interface TreeNode {
readonly description?: string;
readonly position?: number;
readonly indexDoc?: DocInfo;
readonly optionalLinks?: readonly OptionalLink[];
}

/**
Expand Down
Loading