diff --git a/docs/multi-version.md b/docs/multi-version.md index 6dbef9a..377c793 100644 --- a/docs/multi-version.md +++ b/docs/multi-version.md @@ -117,7 +117,7 @@ build/ └── llms-full.txt ``` -Each version's label is its `versions..label` from the docs plugin config, or its name otherwise, so the current docs are labeled `current` unless you set `versions.current.label`. If `versions.json` is absent, the plugin generates only the current docs. +Each version's label follows Docusaurus: its `versions..label` from the docs plugin config when set, otherwise `Next` for the current docs and the version name for any other version. If `versions.json` is absent, the plugin generates only the current docs. ## Output layout and version identity diff --git a/package.json b/package.json index 0a491b3..caee610 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "format:check": "oxfmt --check", "cleanup": "node cleanup.js", "prepublishOnly": "npm run build && npm run cleanup", - "test:unit": "node tests/test-plugin-options-validation.js && node tests/test-plugin-validation-integration.js && node tests/test-regex-escaping.js && node tests/test-baseurl-handling.js && node tests/test-baseurl-url-construction.js && node tests/test-baseurl-individual-markdown.js && node tests/test-compound-number-prefixes.js && node tests/test-relative-urls.js && node tests/test-content-cleaning-fences.js && node tests/test-jsx-attr-stripping.js && node tests/test-preserve-components.js && node tests/test-tabitem-attr-edge-cases.js && node tests/test-partial-dollar-replacement.js && node tests/test-partial-fence-masking.js && node tests/test-content-loss.js && node tests/test-output-assembly.js && node tests/test-crlf-handling.js && node tests/test-path-transforms.js && node tests/test-header-deduplication.js && node tests/test-import-removal.js && node tests/test-partials.js && node tests/test-site-alias-partials.js && node tests/test-partial-code-fence-imports.js && node tests/test-mdx-page-body-imports.js && node tests/test-missing-partials.js && node tests/test-circular-imports.js && node tests/test-root-content.js && node tests/test-rewrite-image-urls.js && node tests/test-filenames.js && node tests/test-url-encoding.js && node tests/test-nested-paths.js && node tests/test-filename-sanitization.js && node tests/test-yaml-encoding.js && node tests/test-url-error-handling.js && node tests/test-regex-lastindex.js && node tests/test-whitespace-paths.js && node tests/test-unique-identifier-iteration-limit.js && node tests/test-error-handling.js && node tests/test-file-io-error-handling.js && node tests/test-parallel-processing.js && node tests/test-path-length-validation.js && node tests/test-bom-handling.js && node tests/test-batch-processing.js && node tests/test-input-validation.js && node tests/test-add-md-extension.js && node tests/test-import-description-skip.js && node tests/test-description-non-prose.js && node tests/test-filename-sanitization-improved.js && node tests/test-content-edge-cases.js", - "test:integration": "node tests/test-path-transformation.js && node tests/test-multi-doc.js && node tests/test-draft-integration.js && node tests/test-md-extension-generation.js && node tests/test-regression-fixes.js && node tests/test-multi-version.js && node tests/test-versions-i18n.js && node tests/test-review-followups.js && node tests/test-frontmatter-slug-priority.js && node tests/test-numeric-frontmatter-slug.js && node tests/test-index-filename-case.js && node tests/test-log-level-and-batch-size.js && node tests/test-multi-section-route-scoping.js && node tests/test-path-safety.js && node tests/test-url-resolution.js && node tests/test-small-fixes.js", + "test:unit": "node tests/test-plugin-options-validation.js && node tests/test-plugin-validation-integration.js && node tests/test-regex-escaping.js && node tests/test-baseurl-handling.js && node tests/test-baseurl-url-construction.js && node tests/test-baseurl-individual-markdown.js && node tests/test-compound-number-prefixes.js && node tests/test-relative-urls.js && node tests/test-content-cleaning-fences.js && node tests/test-jsx-attr-stripping.js && node tests/test-preserve-components.js && node tests/test-tabitem-attr-edge-cases.js && node tests/test-partial-dollar-replacement.js && node tests/test-partial-fence-masking.js && node tests/test-content-loss.js && node tests/test-output-assembly.js && node tests/test-crlf-handling.js && node tests/test-path-transforms.js && node tests/test-header-deduplication.js && node tests/test-import-removal.js && node tests/test-partials.js && node tests/test-site-alias-partials.js && node tests/test-partial-code-fence-imports.js && node tests/test-mdx-page-body-imports.js && node tests/test-missing-partials.js && node tests/test-circular-imports.js && node tests/test-root-content.js && node tests/test-rewrite-image-urls.js && node tests/test-filenames.js && node tests/test-url-encoding.js && node tests/test-nested-paths.js && node tests/test-filename-sanitization.js && node tests/test-yaml-encoding.js && node tests/test-url-error-handling.js && node tests/test-regex-lastindex.js && node tests/test-whitespace-paths.js && node tests/test-unique-identifier-iteration-limit.js && node tests/test-error-handling.js && node tests/test-file-io-error-handling.js && node tests/test-parallel-processing.js && node tests/test-path-length-validation.js && node tests/test-bom-handling.js && node tests/test-batch-processing.js && node tests/test-input-validation.js && node tests/test-add-md-extension.js && node tests/test-import-description-skip.js && node tests/test-description-non-prose.js && node tests/test-filename-sanitization-improved.js && node tests/test-content-edge-cases.js && node tests/test-draft-filtering.js && node tests/test-individual-markdown-generation.js && node tests/test-component-name-escaping.js && node tests/test-description-extraction.js && node tests/test-double-slash-url.js && node tests/test-frontmatter-validation.js && node tests/test-ignored-files-warning.js && node tests/test-numbered-prefixes.js && node tests/test-path-bounds-checking.js && node tests/test-path-collision-limit.js && node tests/test-path-transformation-ignore.js && node tests/test-preserve-directory-structure.js && node tests/test-symlink-loops.js && node tests/test-type-guards.js && node tests/test-windows-path-normalization.js", + "test:integration": "node tests/test-path-transformation.js && node tests/test-multi-doc.js && node tests/test-draft-integration.js && node tests/test-md-extension-generation.js && node tests/test-regression-fixes.js && node tests/test-multi-version.js && node tests/test-versions-i18n.js && node tests/test-review-followups.js && node tests/test-frontmatter-slug-priority.js && node tests/test-numeric-frontmatter-slug.js && node tests/test-index-filename-case.js && node tests/test-log-level-and-batch-size.js && node tests/test-multi-section-route-scoping.js && node tests/test-path-safety.js && node tests/test-url-resolution.js && node tests/test-small-fixes.js && node tests/test-versions-edges.js && node tests/test-ignore-path-with-draft.js && node tests/test-pattern-matching.js && node tests/test-preserve-directory-structure-passthrough.js && node tests/test-route-resolution-helpers.js", "test": "npm run build && npm run test:unit && npm run test:integration" }, "dependencies": { diff --git a/src/index.ts b/src/index.ts index f31df0d..00a25a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -603,6 +603,8 @@ function readDocsPluginConfigs(siteConfig: unknown): DocsPluginConfig[] { * - a version is served at `///`, where the * version path is its configured `path`, else '' for the last version, * 'next' for `current`, and the version name otherwise + * - a version's label is its configured `label`, else 'Next' for `current` + * and the version name otherwise (`getVersionLabel`) * Each version writes its files under `//`, so the version * served at the unprefixed route owns the root files. */ @@ -666,7 +668,10 @@ function detectVersions( ]; } - return { name, label: meta.label, docsDir, path: versionPath, routePrefix: '' }; + // Docusaurus labels a version with its configured label, else 'Next' for + // the current docs and the version name otherwise. + const label = meta.label ?? (name === 'current' ? 'Next' : name); + return { name, label, docsDir, path: versionPath, routePrefix: '' }; }); } diff --git a/src/processor.ts b/src/processor.ts index 0d6994e..a366df5 100644 --- a/src/processor.ts +++ b/src/processor.ts @@ -181,9 +181,12 @@ export async function processMarkdownFile( const content = await readFile(contentFilePath); const { data, content: markdownContent } = matter(content); - // Skip draft files (accept both boolean true and the string "true", which - // some editors emit as quoted frontmatter). - if (data.draft === true || data.draft === 'true') { + // Skip draft files. Docusaurus validates `draft` as a Joi boolean with + // conversion, so the string "true" in any letter case is a draft too. + if ( + data.draft === true || + (typeof data.draft === 'string' && data.draft.toLowerCase() === 'true') + ) { return null; } @@ -526,14 +529,17 @@ async function resolveDocumentUrl( // In multi-version mode, restrict matching to routes owned by this version so // links resolve within the correct subtree (e.g. a 'stable' doc links to - // /stable/... and the root version's links avoid versioned subtrees). + // /stable/... and the root version's links avoid versioned subtrees). Blog + // posts aren't versioned, so they match the blog's routes in any version. const basePath = getSiteBasePath(context.siteUrl); - let scopedRoutes = scopeRoutesToVersion( - context.routesPaths, - basePath, - context.routePrefix, - context.siblingPrefixes, - ); + let scopedRoutes = isBlogFile + ? context.routesPaths + : scopeRoutesToVersion( + context.routesPaths, + basePath, + context.routePrefix, + context.siblingPrefixes, + ); // The section's root route, relative to the baseUrl. Routes beneath it // belong to this section; the blog isn't versioned. diff --git a/tests/test-array-bounds-checking.js b/tests/test-array-bounds-checking.js deleted file mode 100644 index 3b319c9..0000000 --- a/tests/test-array-bounds-checking.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Unit tests for array bounds checking in generator.ts - * - * Run with: node tests/test-array-bounds-checking.js - */ - -/** - * Simulates the cleanDescriptionForToc function from generator.ts - */ -function cleanDescriptionForToc(description) { - if (!description) return ''; - - // Get just the first line for TOC display - const lines = description.split('\n'); - const firstLine = lines.length > 0 ? lines[0] : ''; - - // Remove heading markers only at the beginning of the line - const cleaned = firstLine.replace(/^(#+)\s+/g, ''); - - // Truncate if too long (150 characters max with ellipsis) - return cleaned.length > 150 ? cleaned.substring(0, 147) + '...' : cleaned; -} - -/** - * Simulates the header deduplication logic from generateLLMFile function - */ -function getFirstLineFromContent(content) { - const trimmedContent = content.trim(); - const contentLines = trimmedContent.split('\n'); - const firstLine = contentLines.length > 0 ? contentLines[0] : ''; - return firstLine; -} - -// Test cases for empty string edge cases -const testCases = [ - { - name: 'Empty string description', - description: '', - expectedFirstLine: '', - expectedCleaned: '', - }, - { - name: 'Single line description', - description: 'This is a single line', - expectedFirstLine: 'This is a single line', - expectedCleaned: 'This is a single line', - }, - { - name: 'Multi-line description', - description: 'First line\nSecond line\nThird line', - expectedFirstLine: 'First line', - expectedCleaned: 'First line', - }, - { - name: 'Description with heading marker', - description: '# Heading\nContent here', - expectedFirstLine: '# Heading', - expectedCleaned: 'Heading', - }, - { - name: 'Description with multiple heading markers', - description: '## Sub Heading\nContent here', - expectedFirstLine: '## Sub Heading', - expectedCleaned: 'Sub Heading', - }, - { - name: 'Null description (treated as empty)', - description: null, - expectedFirstLine: '', - expectedCleaned: '', - }, - { - name: 'Undefined description (treated as empty)', - description: undefined, - expectedFirstLine: '', - expectedCleaned: '', - }, - { - name: 'Whitespace-only description', - description: ' \n \n ', - expectedFirstLine: ' ', - expectedCleaned: ' ', - }, - { - name: 'Very long description (should truncate)', - description: 'a'.repeat(200), - expectedFirstLine: 'a'.repeat(200), - expectedCleaned: 'a'.repeat(147) + '...', - }, - { - name: 'Description with newline at start', - description: '\nFirst line after newline', - expectedFirstLine: '', - expectedCleaned: '', - }, -]; - -// Test cases for content first line extraction -const contentTestCases = [ - { - name: 'Empty content', - content: '', - expectedFirstLine: '', - }, - { - name: 'Single line content', - content: 'Single line', - expectedFirstLine: 'Single line', - }, - { - name: 'Multi-line content', - content: 'First line\nSecond line', - expectedFirstLine: 'First line', - }, - { - name: 'Content with leading/trailing whitespace', - content: ' \n First line after whitespace \n ', - expectedFirstLine: 'First line after whitespace', - }, - { - name: 'Content with heading', - content: '# Heading\nParagraph content', - expectedFirstLine: '# Heading', - }, -]; - -// Run tests -function runTests() { - console.log('=== Testing cleanDescriptionForToc ===\n'); - - let passCount = 0; - let failCount = 0; - - testCases.forEach((test, index) => { - try { - const result = cleanDescriptionForToc(test.description); - const passed = result === test.expectedCleaned; - - if (passed) { - console.log(`✅ Test ${index + 1}: ${test.name}`); - passCount++; - } else { - console.log(`❌ Test ${index + 1}: ${test.name}`); - console.log(` Expected: "${test.expectedCleaned}"`); - console.log(` Got: "${result}"`); - failCount++; - } - } catch (error) { - console.log(`❌ Test ${index + 1}: ${test.name} (EXCEPTION)`); - console.log(` Error: ${error.message}`); - failCount++; - } - }); - - console.log(`\n=== Testing getFirstLineFromContent ===\n`); - - contentTestCases.forEach((test, index) => { - try { - const result = getFirstLineFromContent(test.content); - const passed = result === test.expectedFirstLine; - - if (passed) { - console.log(`✅ Content Test ${index + 1}: ${test.name}`); - passCount++; - } else { - console.log(`❌ Content Test ${index + 1}: ${test.name}`); - console.log(` Expected: "${test.expectedFirstLine}"`); - console.log(` Got: "${result}"`); - failCount++; - } - } catch (error) { - console.log(`❌ Content Test ${index + 1}: ${test.name} (EXCEPTION)`); - console.log(` Error: ${error.message}`); - failCount++; - } - }); - - console.log(`\n=== Test Results ===`); - console.log(`Total tests: ${passCount + failCount}`); - console.log(`Passed: ${passCount}`); - console.log(`Failed: ${failCount}`); - - if (failCount === 0) { - console.log('\n✅ All tests passed!'); - } else { - console.log(`\n❌ ${failCount} test(s) failed.`); - process.exit(1); - } -} - -// Edge case validation tests -function runEdgeCaseValidation() { - console.log('\n\n=== Edge Case Validation ===\n'); - - const edgeCases = [ - { - name: 'No undefined propagation from empty split', - test: () => { - const emptyString = ''; - const lines = emptyString.split('\n'); - const firstLine = lines.length > 0 ? lines[0] : ''; - return firstLine === '' && firstLine !== undefined; - }, - }, - { - name: 'No undefined propagation from whitespace-only split', - test: () => { - const whitespace = ' '; - const lines = whitespace.split('\n'); - const firstLine = lines.length > 0 ? lines[0] : ''; - return firstLine === ' ' && firstLine !== undefined; - }, - }, - { - name: 'Handling of string with only newline', - test: () => { - const onlyNewline = '\n'; - const lines = onlyNewline.split('\n'); - const firstLine = lines.length > 0 ? lines[0] : ''; - return firstLine === '' && lines.length === 2; // split('\n') on '\n' gives ['', ''] - }, - }, - { - name: 'Consistent behavior with replace on empty string', - test: () => { - const emptyString = ''; - const cleaned = emptyString.replace(/^(#+)\s+/g, ''); - return cleaned === ''; - }, - }, - { - name: 'Safe substring on empty string', - test: () => { - const emptyString = ''; - try { - const result = - emptyString.length > 150 ? emptyString.substring(0, 147) + '...' : emptyString; - return result === ''; - } catch { - return false; - } - }, - }, - ]; - - let validationPass = 0; - let validationFail = 0; - - edgeCases.forEach((testCase, index) => { - try { - const result = testCase.test(); - if (result) { - console.log(`✅ Edge Case ${index + 1}: ${testCase.name}`); - validationPass++; - } else { - console.log(`❌ Edge Case ${index + 1}: ${testCase.name}`); - validationFail++; - } - } catch (error) { - console.log(`❌ Edge Case ${index + 1}: ${testCase.name} (EXCEPTION)`); - console.log(` Error: ${error.message}`); - validationFail++; - } - }); - - console.log( - `\nEdge Case Validation: ${validationPass}/${validationPass + validationFail} passed`, - ); - - return validationFail === 0; -} - -// Run all tests -runTests(); -const edgeCasesPass = runEdgeCaseValidation(); - -if (!edgeCasesPass) { - console.log('\n❌ Some edge case validations failed.'); - process.exit(1); -} - -console.log('\n✅ All array bounds checking tests passed successfully!'); diff --git a/tests/test-description-extraction.js b/tests/test-description-extraction.js index a417ab1..c751d8a 100644 --- a/tests/test-description-extraction.js +++ b/tests/test-description-extraction.js @@ -1,107 +1,39 @@ /** - * Unit tests for description extraction and cleaning functionality + * Unit tests for description extraction and cleaning functionality, through + * the real processMarkdownFile and cleanMarkdownContent. + * + * In YAML, ` #` starts a comment, so an unquoted front matter value such as + * `description: Learn about the # symbol` parses as "Learn about the", the + * same value Docusaurus reads; a `#` that belongs to the text needs quotes. * * Run with: node tests/test-description-extraction.js */ -const matter = require('gray-matter'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { processMarkdownFile } = require('../lib/processor'); const { cleanMarkdownContent } = require('../lib/utils'); -// Simplified version of the processor's description extraction logic for testing -function extractAndCleanDescription(content) { - const { data, content: markdownContent } = matter(content); - - // Get description from frontmatter or first paragraph - let description = ''; +let passed = 0; +let failed = 0; - // First priority: Use frontmatter description if available - if (data.description) { - description = data.description; +function check(name, actual, expected) { + if (actual === expected) { + console.log(` ✅ PASS: ${name}`); + passed++; } else { - // Second priority: Find the first non-heading paragraph - const paragraphs = markdownContent.split('\n\n'); - for (const para of paragraphs) { - const trimmedPara = para.trim(); - // Skip empty paragraphs and headings - if (trimmedPara && !trimmedPara.startsWith('#')) { - description = trimmedPara; - break; - } - } - - // Third priority: If still no description, use the first heading's content - if (!description) { - const firstHeadingMatch = markdownContent.match(/^#\s+(.*?)$/m); - if (firstHeadingMatch && firstHeadingMatch[1]) { - description = firstHeadingMatch[1].trim(); - } - } + console.log(` ❌ FAIL: ${name}`); + console.log(` expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + failed++; } - - // Only remove heading markers at the beginning of descriptions or lines - // This preserves # characters that are part of the content - if (description) { - // Original approach had issues with hashtags inside content - // Fix: Only remove # symbols at the beginning of lines or description - // that are followed by a space (actual heading markers) - description = description.replace(/^(#+)\s+/gm, ''); - - // Special handling for description frontmatter with heading markers - if (data.description && data.description.startsWith('#')) { - // If the description in frontmatter starts with a heading marker, - // we should preserve it in the extracted description - description = description.replace(/^#+\s+/, ''); - } - - // Preserve inline hashtags (not heading markers) - // We don't want to treat hashtags in the middle of content as headings - } - - return description; -} - -// Function to validate a description for problematic content -function validateDescription(description) { - // Check for heading markers at the beginning of lines (which would be headings) - const hasHeadingMarkers = description.match(/^#+\s+/m) !== null; - - // Check for inline hashtags that are not heading markers - const hasInlineHashtags = description.includes('#') && !hasHeadingMarkers; - - // Check for potential HTML tags - const hasPotentialHtml = /<[^>]+>/g.test(description); - - // Check if description is too long (arbitrary limit for testing) - const isTooLong = description.length > 500; - - return { - isValid: !hasHeadingMarkers && !hasPotentialHtml && !isTooLong, - issues: { - hasHeadingMarkers, - hasInlineHashtags, - hasPotentialHtml, - isTooLong, - }, - }; } -// Simulating the generator's description cleaning for TOC items -function cleanDescriptionForToc(description) { - if (!description) return ''; - - // Get just the first line for TOC display - const firstLine = description.split('\n')[0]; +const longText = Array(20) + .fill('This is a very long description that should be truncated for TOC items. ') + .join('') + .trim(); - // Remove heading markers only at the beginning of the line - // Be careful to only remove actual heading markers (# followed by space at beginning) - // and not hashtag symbols that are part of the content (inline hashtags) - const cleaned = firstLine.replace(/^(#+)\s+/g, ''); - - // Truncate if too long - return cleaned.length > 150 ? cleaned.substring(0, 147) + '...' : cleaned; -} - -// Modified test cases to test ideal behavior where hashtags are properly preserved const testCases = [ { name: 'Description from frontmatter', @@ -113,8 +45,7 @@ description: This is a test description # Test Header This is some content.`, - expectedDescription: 'This is a test description', - expectedToc: 'This is a test description', + expected: 'This is a test description', }, { name: 'Description from first paragraph', @@ -127,11 +58,22 @@ title: Test Page This is the first paragraph that should become the description. This is other content.`, - expectedDescription: 'This is the first paragraph that should become the description.', - expectedToc: 'This is the first paragraph that should become the description.', + expected: 'This is the first paragraph that should become the description.', }, { - name: 'Description with inline hashtag symbol', + name: 'Quoted description keeps an inline hashtag symbol', + input: `--- +title: Test Page +description: "Learn about the # symbol in Markdown" +--- + +# Test Header + +Content here.`, + expected: 'Learn about the # symbol in Markdown', + }, + { + name: 'Unquoted " #" starts a YAML comment', input: `--- title: Test Page description: Learn about the # symbol in Markdown @@ -140,26 +82,22 @@ description: Learn about the # symbol in Markdown # Test Header Content here.`, - // The improved implementation should preserve the full description with hashtag - expectedDescription: 'Learn about the # symbol in Markdown', - expectedToc: 'Learn about the # symbol in Markdown', + expected: 'Learn about the', }, { - name: 'Description with heading marker prefix that should be removed', + name: 'A description that is only a YAML comment falls back to the first paragraph', input: `--- title: Test Page -description: # This should have the hashtag removed +description: # This is a YAML comment --- # Test Header Content here.`, - // With the improved implementation, frontmatter heading markers should be removed - expectedDescription: 'This should have the hashtag removed', - expectedToc: 'This should have the hashtag removed', + expected: 'Content here.', }, { - name: 'Multi-line description', + name: 'Multi-line description is kept whole', input: `--- title: Test Page description: | @@ -171,10 +109,8 @@ description: | # Test Header Content here.`, - // There's an extra newline at the end in the current implementation - expectedDescription: + expected: 'First line of description\nSecond line that should be included\nThird line with some # characters that should be preserved\n', - expectedToc: 'First line of description', }, { name: 'Description from header when no paragraphs available', @@ -185,181 +121,49 @@ title: Test Page # This Will Become The Description # Another Heading`, - expectedDescription: 'This Will Become The Description', - expectedToc: 'This Will Become The Description', + expected: 'This Will Become The Description', }, { - name: 'Description with HTML', + name: 'Frontmatter description with HTML is kept as written', input: `--- title: Test Page -description: This has HTML that should be flagged +description: This has HTML in it --- # Test Header Content here.`, - expectedDescription: 'This has HTML that should be flagged', - expectedToc: 'This has HTML that should be flagged', + expected: 'This has HTML in it', }, { - name: 'Very long description', + name: 'Very long frontmatter description is kept whole (the TOC truncates it)', input: `--- title: Test Page -description: ${Array(20).fill('This is a very long description that should be truncated for TOC items. ').join('')} +description: ${longText} --- # Test Header Content here.`, - // Adjust the length to match the actual implementation - note the exact string generation - expectedDescription: Array(20) - .fill('This is a very long description that should be truncated for TOC items. ') - .join('') - .substring(0, 1439), - // Actually tested the output length - it's 150 characters including the ellipsis - expectedToc: ( - Array(5) - .fill('This is a very long description that should be truncated for TOC items. ') - .join('') - .substring(0, 147) + '...' - ).substring(0, 150), + expected: longText, }, ]; -// Run tests -function runTests() { - console.log('Running description extraction and cleaning tests...\n'); - - let passCount = 0; - let foundIssues = { - headingMarkers: false, - inlineHashtags: false, - potentialHtml: false, - tooLong: false, - extractionMismatch: false, - tocMismatch: false, - }; - - testCases.forEach((test, index) => { - console.log(`Test ${index + 1}: ${test.name}`); - - // Extract description - const description = extractAndCleanDescription(test.input); - console.log( - ` Extracted description: "${description.length > 50 ? description.substring(0, 47) + '...' : description}"`, - ); - console.log( - ` Expected description: "${test.expectedDescription.length > 50 ? test.expectedDescription.substring(0, 47) + '...' : test.expectedDescription}"`, - ); - - // For cases with hashtags, log more detailed information - if (description.includes('#') || test.expectedDescription.includes('#')) { - console.log(' DEBUGGING HASHTAGS:'); - console.log(` - Actual: "${JSON.stringify(description)}"`); - console.log(` - Expected: "${JSON.stringify(test.expectedDescription)}"`); - } - - // Validate description - const validation = validateDescription(description); - if (!validation.isValid) { - console.log(' Validation issues:'); - if (validation.issues.hasHeadingMarkers) { - console.log(' - Contains heading markers (#)'); - foundIssues.headingMarkers = true; - } - if (validation.issues.hasInlineHashtags) { - console.log(' - Contains inline hashtags that need preservation'); - foundIssues.inlineHashtags = true; - } - if (validation.issues.hasPotentialHtml) { - console.log(' - Contains potential HTML tags'); - foundIssues.potentialHtml = true; - } - if (validation.issues.isTooLong) { - console.log(' - Description is too long'); - foundIssues.tooLong = true; - } +async function runTests() { + console.log('Running description extraction tests...\n'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-description-extraction-')); + try { + for (const [index, test] of testCases.entries()) { + const file = path.join(dir, `case-${index}.md`); + fs.writeFileSync(file, test.input); + const doc = await processMarkdownFile(file, dir, 'https://example.com', 'docs'); + check(test.name, doc ? doc.description : null, test.expected); } - - // Test TOC cleaning - const tocDescription = cleanDescriptionForToc(description); - console.log( - ` TOC description: "${tocDescription.length > 50 ? tocDescription.substring(0, 47) + '...' : tocDescription}"`, - ); - console.log( - ` Expected TOC: "${test.expectedToc.length > 50 ? test.expectedToc.substring(0, 47) + '...' : test.expectedToc}"`, - ); - - // For the very long description test, show the truncation behavior - if (test.name.includes('Very long description')) { - console.log(' DEBUGGING TRUNCATION:'); - console.log(` - Actual length: ${tocDescription.length}`); - console.log(` - Expected length: ${test.expectedToc.length}`); - console.log(` - Truncated at: ${tocDescription.endsWith('...') ? 'Yes' : 'No'}`); - } - - // Check if the test passes - const descriptionMatches = description === test.expectedDescription; - const tocMatches = tocDescription === test.expectedToc; - - if (descriptionMatches && tocMatches) { - console.log(' ✅ PASS'); - passCount++; - } else { - console.log(' ❌ FAIL'); - if (!descriptionMatches) { - console.log(' - Description does not match expected'); - console.log( - ` - Actual: ${description.substring(0, 30)}... (${description.length} chars)`, - ); - console.log( - ` - Expected: ${test.expectedDescription.substring(0, 30)}... (${test.expectedDescription.length} chars)`, - ); - foundIssues.extractionMismatch = true; - } - if (!tocMatches) { - console.log(' - TOC description does not match expected'); - console.log( - ` - Actual: ${tocDescription.substring(0, 30)}... (${tocDescription.length} chars)`, - ); - console.log( - ` - Expected: ${test.expectedToc.substring(0, 30)}... (${test.expectedToc.length} chars)`, - ); - foundIssues.tocMismatch = true; - } - } - - console.log(''); - }); - - console.log(`Results: ${passCount} of ${testCases.length} tests passed.`); - - // Print overall recommendations based on actual issues found - console.log('\nRecommendations based on test results:'); - if (foundIssues.headingMarkers) { - console.log('- Ensure proper heading marker removal from descriptions'); - } - if (foundIssues.inlineHashtags) { - console.log('- Ensure hashtag symbols that are part of content are preserved'); - } - if (foundIssues.potentialHtml) { - console.log('- Consider HTML sanitization for descriptions'); - } - if (foundIssues.tooLong) { - console.log('- Implement appropriate truncation for long descriptions'); - } - if (foundIssues.extractionMismatch) { - console.log('- Fix description extraction logic to match expected behavior'); - } - if (foundIssues.tocMismatch) { - console.log('- Fix TOC description cleaning to match expected behavior'); - } - if (passCount === testCases.length) { - console.log('- All tests passed! No issues detected.'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); } } -// XML Preservation Tests function runXmlPreservationTests() { console.log('\n=== XML Preservation Tests ===\n'); @@ -378,22 +182,21 @@ function runXmlPreservationTests() { }, ]; - let xmlPassCount = 0; - xmlTests.forEach((test, i) => { + for (const test of xmlTests) { const cleaned = cleanMarkdownContent(test.input); const hasAll = test.shouldHave.every((tag) => cleaned.includes(tag)); const hasNone = test.shouldNotHave.every((tag) => !cleaned.includes(tag)); - - if (hasAll && hasNone) { - console.log(`✅ XML Test ${i + 1}: ${test.name}`); - xmlPassCount++; - } else { - console.log(`❌ XML Test ${i + 1}: ${test.name}`); - } - }); - - console.log(`\nXML Tests: ${xmlPassCount}/${xmlTests.length} passed\n`); + check(test.name, hasAll && hasNone, true); + } } -runTests(); -runXmlPreservationTests(); +runTests() + .then(() => { + runXmlPreservationTests(); + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/tests/test-description-truncation.js b/tests/test-description-truncation.js deleted file mode 100644 index d1d3418..0000000 --- a/tests/test-description-truncation.js +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Unit tests for description truncation edge cases - * - * Tests the improved word boundary truncation logic that was added - * to fix Issue #15 & #29 - * - * Run with: node tests/test-description-truncation.js - */ - -const assert = require('assert'); - -/** - * Constants matching those in generator.ts - */ -const MAX_DESCRIPTION_LENGTH = 150; -const DESCRIPTION_TRUNCATE_AT = 147; -const WORD_BOUNDARY_MIN_RATIO = 0.8; - -/** - * Clean a description for use in a TOC item (matching generator.ts) - */ -function cleanDescriptionForToc(description) { - if (!description) return ''; - - // Get just the first line for TOC display - const lines = description.split('\n'); - const firstLine = lines.length > 0 ? lines[0] : ''; - - // Remove heading markers only at the beginning of the line - const cleaned = firstLine.replace(/^(#+)\s+/g, ''); - - // Truncate if too long - if (cleaned.length > MAX_DESCRIPTION_LENGTH) { - let truncated = cleaned.substring(0, DESCRIPTION_TRUNCATE_AT); - // Truncate at last word boundary to avoid cutting words in half - const lastSpace = truncated.lastIndexOf(' '); - if (lastSpace > MAX_DESCRIPTION_LENGTH * WORD_BOUNDARY_MIN_RATIO) { - truncated = truncated.substring(0, lastSpace); - } - return truncated + '...'; - } - - return cleaned; -} - -// Test cases for description truncation -const tests = [ - { - name: 'Short description (no truncation)', - input: 'This is a short description', - expected: 'This is a short description', - expectsTruncation: false, - }, - { - name: 'Exactly at max length', - input: 'a'.repeat(MAX_DESCRIPTION_LENGTH), - expected: 'a'.repeat(MAX_DESCRIPTION_LENGTH), - expectsTruncation: false, - }, - { - name: 'One character over max length', - input: 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1), - expected: 'a'.repeat(DESCRIPTION_TRUNCATE_AT) + '...', - expectsTruncation: true, - }, - { - name: 'Long description with spaces (word boundary truncation)', - input: - 'This is a very long description that should be truncated at a word boundary to avoid cutting words in half and making the text look awkward when displayed in the table of contents', - expected: function (result) { - // Should be truncated with ellipsis - return ( - result.endsWith('...') && - result.length <= MAX_DESCRIPTION_LENGTH && - // Should not end with a partial word (space before the ellipsis or at a word break) - (result.substring(0, result.length - 3).match(/\s$/) || true) - ); - }, - expectsTruncation: true, - }, - { - name: 'Long description without spaces (no word boundary)', - input: 'a'.repeat(200), - expected: 'a'.repeat(DESCRIPTION_TRUNCATE_AT) + '...', - expectsTruncation: true, - }, - { - name: 'Description with space near the end', - input: 'a'.repeat(140) + ' test more content here', - expected: function (result) { - // Should truncate at the space before "test" - // The space is at position 140, which is > 120 (80% of 150) - // So it should truncate at the space and include " test" - return result.endsWith('...') && result.length <= MAX_DESCRIPTION_LENGTH; - }, - expectsTruncation: true, - }, - { - name: 'Description with space too early (below 80% threshold)', - input: 'word ' + 'a'.repeat(200), - expected: 'word ' + 'a'.repeat(DESCRIPTION_TRUNCATE_AT - 5) + '...', - expectsTruncation: true, - }, - { - name: 'Multi-line description (only first line used)', - input: 'First line that is quite long and should be used\nSecond line should be ignored', - expected: 'First line that is quite long and should be used', - expectsTruncation: false, - }, - { - name: 'Description with heading marker', - input: '## This heading marker should be removed', - expected: 'This heading marker should be removed', - expectsTruncation: false, - }, - { - name: 'Long description with heading marker', - input: '# ' + 'a'.repeat(200), - expected: 'a'.repeat(DESCRIPTION_TRUNCATE_AT) + '...', - expectsTruncation: true, - }, - { - name: 'Description with inline hashtag (preserved)', - input: 'Learn about the # symbol in programming', - expected: 'Learn about the # symbol in programming', - expectsTruncation: false, - }, - { - name: 'Empty string', - input: '', - expected: '', - expectsTruncation: false, - }, - { - name: 'Only whitespace', - input: ' \t \n ', - expected: ' \t ', // First line only (before \n) - expectsTruncation: false, - }, - { - name: 'Word boundary at exactly 80% threshold', - input: - 'a'.repeat(Math.floor(MAX_DESCRIPTION_LENGTH * WORD_BOUNDARY_MIN_RATIO)) + - ' ' + - 'b'.repeat(100), - expected: function (result) { - // The space is at position 120, which equals 120 (80% of 150) - // Since lastSpace (120) > MAX_DESCRIPTION_LENGTH * WORD_BOUNDARY_MIN_RATIO (120), condition is true - // So it will truncate at the space, but since we're at 147 chars first, it includes some 'b's - return result.endsWith('...') && result.length <= MAX_DESCRIPTION_LENGTH; - }, - expectsTruncation: true, - }, - { - name: 'Word boundary just above 80% threshold', - input: - 'a'.repeat(Math.floor(MAX_DESCRIPTION_LENGTH * WORD_BOUNDARY_MIN_RATIO) + 1) + - ' ' + - 'b'.repeat(100), - expected: function (result) { - // Should truncate at the space - return result.endsWith('...') && !result.includes('b'); - }, - expectsTruncation: true, - }, -]; - -// Run tests -function runTests() { - console.log('Running description truncation tests...\n'); - console.log(`Configuration:`); - console.log(` MAX_DESCRIPTION_LENGTH: ${MAX_DESCRIPTION_LENGTH}`); - console.log(` DESCRIPTION_TRUNCATE_AT: ${DESCRIPTION_TRUNCATE_AT}`); - console.log(` WORD_BOUNDARY_MIN_RATIO: ${WORD_BOUNDARY_MIN_RATIO}`); - console.log( - ` Minimum word boundary position: ${Math.floor(MAX_DESCRIPTION_LENGTH * WORD_BOUNDARY_MIN_RATIO)}\n`, - ); - - let passed = 0; - let failed = 0; - - tests.forEach((test, index) => { - console.log(`Test ${index + 1}: ${test.name}`); - console.log(` Input length: ${test.input.length}`); - - const result = cleanDescriptionForToc(test.input); - console.log(` Output length: ${result.length}`); - console.log(` Output preview: "${result.substring(0, 50)}${result.length > 50 ? '...' : ''}"`); - - let testPassed = false; - - if (typeof test.expected === 'function') { - // Custom validation function - testPassed = test.expected(result); - if (!testPassed) { - console.log(` ❌ FAIL: Custom validation failed`); - console.log(` Full output: "${result}"`); - } else { - console.log(` ✅ PASS`); - } - } else { - // Direct comparison - testPassed = result === test.expected; - if (!testPassed) { - console.log(` ❌ FAIL`); - console.log( - ` Expected: "${test.expected.substring(0, 50)}${test.expected.length > 50 ? '...' : ''}"`, - ); - console.log(` Got: "${result.substring(0, 50)}${result.length > 50 ? '...' : ''}"`); - if (result.length !== test.expected.length) { - console.log(` Length mismatch: expected ${test.expected.length}, got ${result.length}`); - } - } else { - console.log(` ✅ PASS`); - } - } - - // Verify truncation occurred as expected - const hasTruncation = result.endsWith('...'); - if (test.expectsTruncation !== hasTruncation) { - console.log( - ` ⚠️ WARNING: Expected truncation=${test.expectsTruncation}, but got truncation=${hasTruncation}`, - ); - testPassed = false; - } - - // Verify output is within max length - if (result.length > MAX_DESCRIPTION_LENGTH) { - console.log( - ` ❌ ERROR: Output exceeds MAX_DESCRIPTION_LENGTH (${result.length} > ${MAX_DESCRIPTION_LENGTH})`, - ); - testPassed = false; - } - - if (testPassed) { - passed++; - } else { - failed++; - } - - console.log(''); - }); - - console.log('═══════════════════════════════════════'); - console.log(`Results: ${passed}/${tests.length} tests passed`); - if (failed > 0) { - console.log(`❌ ${failed} test(s) failed`); - process.exit(1); - } else { - console.log('✅ All tests passed!'); - } -} - -// Additional integration tests -function runIntegrationTests() { - console.log('\n═══════════════════════════════════════'); - console.log('Integration Tests\n'); - - // Test that constants are consistent - console.log('Test: Constants consistency'); - assert( - DESCRIPTION_TRUNCATE_AT < MAX_DESCRIPTION_LENGTH, - 'DESCRIPTION_TRUNCATE_AT must be less than MAX_DESCRIPTION_LENGTH', - ); - assert( - DESCRIPTION_TRUNCATE_AT + 3 === MAX_DESCRIPTION_LENGTH, - 'DESCRIPTION_TRUNCATE_AT + "..." (3 chars) should equal MAX_DESCRIPTION_LENGTH', - ); - assert( - WORD_BOUNDARY_MIN_RATIO > 0 && WORD_BOUNDARY_MIN_RATIO < 1, - 'WORD_BOUNDARY_MIN_RATIO should be between 0 and 1', - ); - console.log(' ✅ PASS: Constants are consistent\n'); - - // Test that word boundary logic works correctly - console.log('Test: Word boundary logic'); - const testStr = 'a'.repeat(145) + ' test more words here'; - const result = cleanDescriptionForToc(testStr); - // String is > 150, so needs truncation. After taking 147 chars, we have 'a'*145 + ' t' - // lastIndexOf(' ') will find the space at position 145, which is > 120 (80% of 150) - // So it will truncate at position 145 - assert(result.length <= MAX_DESCRIPTION_LENGTH, 'Should be within max length'); - assert(result.endsWith('...'), 'Should end with ellipsis'); - // Should not include 'test' since we truncate at the space before it - assert(!result.includes('test'), 'Should truncate before "test"'); - console.log(' ✅ PASS: Word boundary truncation works\n'); - - // Test that truncation without word boundary works - console.log('Test: Truncation without word boundary'); - const noSpaceStr = 'a'.repeat(200); - const result2 = cleanDescriptionForToc(noSpaceStr); - assert( - result2.length === MAX_DESCRIPTION_LENGTH, - `Should be exactly ${MAX_DESCRIPTION_LENGTH} chars`, - ); - assert(result2.endsWith('...'), 'Should end with ellipsis'); - assert( - result2 === 'a'.repeat(DESCRIPTION_TRUNCATE_AT) + '...', - 'Should truncate at DESCRIPTION_TRUNCATE_AT', - ); - console.log(' ✅ PASS: No word boundary truncation works\n'); - - console.log('✅ All integration tests passed!'); -} - -// Run all tests -try { - runTests(); - runIntegrationTests(); -} catch (error) { - console.error('\n❌ Test execution failed:', error.message); - console.error(error.stack); - process.exit(1); -} diff --git a/tests/test-double-slash-url.js b/tests/test-double-slash-url.js index cf4bad9..03218a3 100644 --- a/tests/test-double-slash-url.js +++ b/tests/test-double-slash-url.js @@ -5,119 +5,120 @@ * When generateMarkdownFiles is true and baseUrl is '/', generated URLs * should not contain double slashes (e.g., https://example.com//docs/intro.md). * - * Root cause: siteUrl already ends with '/' when baseUrl is '/', so naively - * concatenating `${siteUrl}/${urlPath}` produces a double slash. - * - * Fix: Strip trailing slash from siteUrl before concatenating. + * siteUrl already ends with '/' when baseUrl is '/', so the markdown file URL + * that generateIndividualMarkdownFiles returns must join it without doubling + * the slash. */ const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { generateIndividualMarkdownFiles } = require('../lib/generator'); console.log('Testing double-slash URL prevention in markdown file URL generation...\n'); -// Simulate the URL construction logic from generator.ts (generateMarkdownFiles path) -function buildMarkdownFileUrl(siteUrl, urlPath) { - const baseUrlNormalized = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl; - return `${baseUrlNormalized}/${urlPath}`; -} - -// Test cases +// Each case is a site URL and a doc route; the returned markdown URL is the +// route with a .md extension under the site URL. const testCases = [ { name: 'Root baseUrl produces no double slash', siteUrl: 'https://example.com/', - urlPath: 'docs/intro.md', + route: 'docs/intro', expected: 'https://example.com/docs/intro.md', - description: 'siteUrl ending with / should not produce // when urlPath is appended', }, { name: 'No trailing slash siteUrl remains correct', siteUrl: 'https://example.com', - urlPath: 'docs/intro.md', + route: 'docs/intro', expected: 'https://example.com/docs/intro.md', - description: 'siteUrl without trailing slash should produce correct URL', }, { name: 'siteUrl with subpath ending in slash produces no double slash', siteUrl: 'https://example.com/mysite/', - urlPath: 'docs/api/core.md', + route: 'docs/api/core', expected: 'https://example.com/mysite/docs/api/core.md', - description: 'siteUrl with sub-path ending in / should not produce //', }, { name: 'siteUrl with subpath without trailing slash is correct', siteUrl: 'https://example.com/mysite', - urlPath: 'docs/api/core.md', + route: 'docs/api/core', expected: 'https://example.com/mysite/docs/api/core.md', - description: 'siteUrl with sub-path without trailing slash should produce correct URL', }, { - name: 'Root baseUrl with nested urlPath produces no double slash', + name: 'Root baseUrl with nested route produces no double slash', siteUrl: 'https://example.com/', - urlPath: 'guides/advanced/setup.md', + route: 'guides/advanced/setup', expected: 'https://example.com/guides/advanced/setup.md', - description: 'Deeply nested urlPath with root siteUrl should not produce //', }, { name: 'URL with port and root slash produces no double slash', siteUrl: 'https://example.com:8080/', - urlPath: 'docs/intro.md', + route: 'docs/intro', expected: 'https://example.com:8080/docs/intro.md', - description: 'siteUrl with port ending in / should not produce //', - }, - { - name: 'Generated URL does not contain double slash anywhere', - siteUrl: 'https://example.com/', - urlPath: 'docs/intro.md', - check: (result) => !result.replace('://', '').includes('//'), - description: 'The resulting URL must not contain // (excluding the protocol separator)', }, ]; -// Run tests -let passedTests = 0; -let failedTests = 0; +async function runTests() { + let passedTests = 0; + let failedTests = 0; -testCases.forEach((testCase, index) => { - try { - const result = buildMarkdownFileUrl(testCase.siteUrl, testCase.urlPath); + for (const [index, testCase] of testCases.entries()) { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-double-slash-')); + try { + const docUrl = `${testCase.siteUrl.replace(/\/+$/, '')}/${testCase.route}`; + const [doc] = await generateIndividualMarkdownFiles( + [ + { + title: 'Page', + path: `${testCase.route}.md`, + content: 'Body.', + description: 'Description.', + url: docUrl, + }, + ], + outDir, + testCase.siteUrl, + 'docs', + ); - if (testCase.check) { + assert.strictEqual(doc.url, testCase.expected); assert.ok( - testCase.check(result), - `URL "${result}" contains double slash (excluding protocol)`, + !doc.url.replace('://', '').includes('//'), + `URL "${doc.url}" contains a double slash (excluding the protocol)`, ); - } else { - assert.strictEqual( - result, - testCase.expected, - `Expected "${testCase.expected}" but got "${result}"`, + assert.ok( + fs.existsSync(path.join(outDir, `${testCase.route}.md`)), + `markdown file ${testCase.route}.md was not written`, ); - } - console.log(`✓ Test ${index + 1} passed: ${testCase.name}`); - console.log(` siteUrl: "${testCase.siteUrl}", urlPath: "${testCase.urlPath}"`); - console.log(` Result: "${result}"`); - console.log(` ${testCase.description}\n`); - passedTests++; - } catch (error) { - console.error(`✗ Test ${index + 1} failed: ${testCase.name}`); - console.error(` siteUrl: "${testCase.siteUrl}", urlPath: "${testCase.urlPath}"`); - console.error(` ${error.message}\n`); - failedTests++; + console.log(`✓ Test ${index + 1} passed: ${testCase.name}`); + console.log(` siteUrl: "${testCase.siteUrl}" → "${doc.url}"\n`); + passedTests++; + } catch (error) { + console.error(`✗ Test ${index + 1} failed: ${testCase.name}`); + console.error(` siteUrl: "${testCase.siteUrl}", route: "${testCase.route}"`); + console.error(` ${error.message}\n`); + failedTests++; + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } } -}); -// Summary -console.log('='.repeat(60)); -console.log(`Test Summary:`); -console.log(` Total tests: ${testCases.length}`); -console.log(` Passed: ${passedTests}`); -console.log(` Failed: ${failedTests}`); -console.log('='.repeat(60)); + console.log('='.repeat(60)); + console.log(`Test Summary:`); + console.log(` Total tests: ${testCases.length}`); + console.log(` Passed: ${passedTests}`); + console.log(` Failed: ${failedTests}`); + console.log('='.repeat(60)); -if (failedTests > 0) { - process.exit(1); + if (failedTests > 0) { + process.exit(1); + } + console.log('\n✓ All double-slash URL prevention tests passed!'); } -console.log('\n✓ All double-slash URL prevention tests passed!'); +runTests().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/test-draft-filtering.js b/tests/test-draft-filtering.js index 8d0b3b6..f16f080 100644 --- a/tests/test-draft-filtering.js +++ b/tests/test-draft-filtering.js @@ -52,6 +52,10 @@ This content should appear in llms.txt.`, }, }, { + // Docusaurus validates front matter with Joi in convert mode + // (utils-validation validateFrontMatter: `convert: true`; `draft` is + // `Joi.boolean()` in validationSchemas.js), which reads the string "true" + // as boolean true, so a quoted "true" is still a draft. name: 'Should exclude pages with draft: "true" (string)', content: `--- title: String Draft Page @@ -60,11 +64,35 @@ draft: "true" # This is a draft page with string value -This should still be included as draft is a string, not boolean.`, +Docusaurus converts the string to a boolean, so this page is a draft.`, + expectedResult: null, + }, + { + // Joi's boolean conversion ignores case: "TRUE" and "True" are true. + name: 'Should exclude pages with draft: "TRUE" (any case)', + content: `--- +title: Upper Draft Page +draft: "TRUE" +--- + +# Upper-case draft + +Docusaurus reads "TRUE" as true, so this page is a draft.`, + expectedResult: null, + }, + { + name: 'Should include pages with draft: "FALSE"', + content: `--- +title: Upper Published Page +draft: "FALSE" +--- + +# Upper-case published + +Docusaurus reads "FALSE" as false.`, expectedResult: { - title: 'String Draft Page', - description: 'This is a draft page with string value', - // Other fields will be validated in the test + title: 'Upper Published Page', + description: 'Docusaurus reads "FALSE" as false.', }, }, ]; @@ -190,11 +218,10 @@ This is another published article.`, true, // includeUnmatched ); - // Count non-draft files - const expectedNonDraftCount = allFiles.filter((file) => { - const content = fs.readFileSync(file, 'utf-8'); - return !content.includes('draft: true'); - }).length; + // Non-draft files: the test cases without an expected null result, plus + // published1.md and published2.md. + const expectedNonDraftCount = + testCases.filter((testCase) => testCase.expectedResult !== null).length + 2; if (processedDocs.length === expectedNonDraftCount) { console.log(`✅ processFilesWithPatterns correctly filtered draft pages`); diff --git a/tests/test-ignore-path-with-draft.js b/tests/test-ignore-path-with-draft.js index 5736d7a..afe6bda 100644 --- a/tests/test-ignore-path-with-draft.js +++ b/tests/test-ignore-path-with-draft.js @@ -202,7 +202,17 @@ async function testDraftFilteringVsIgnorePatterns() { console.log('- ignore patterns filter files before processing (path-based)'); console.log('- ignore patterns can exclude files regardless of their draft status'); - return true; + const titles = (docs) => docs.map((doc) => doc.title).sort(); + const noFilteringOk = + JSON.stringify(titles(noFiltering)) === + JSON.stringify(['Advanced Tutorial', 'Draft Naming Convention', 'Getting Started', 'Home']); + const withIgnoreOk = + JSON.stringify(titles(withIgnore)) === + JSON.stringify(['Advanced Tutorial', 'Getting Started', 'Home']); + console.log(`\n✅ draft: true page excluded, draft-named page kept: ${noFilteringOk}`); + console.log(`✅ "**/draft*.md" excludes both draft-named files: ${withIgnoreOk}`); + + return noFilteringOk && withIgnoreOk; } // Run all tests diff --git a/tests/test-individual-markdown-generation.js b/tests/test-individual-markdown-generation.js index 9a2d69c..3525ee3 100644 --- a/tests/test-individual-markdown-generation.js +++ b/tests/test-individual-markdown-generation.js @@ -147,7 +147,7 @@ async function runIndividualMarkdownGenerationTests() { try { // Generate individual markdown files - await generateIndividualMarkdownFiles( + const result = await generateIndividualMarkdownFiles( testCase.docs, testDir, testCase.siteUrl, @@ -340,7 +340,7 @@ async function testEdgeCases() { console.log(`Edge Case Test: ${testCase.name}`); try { - await generateIndividualMarkdownFiles( + const result = await generateIndividualMarkdownFiles( testCase.docs, testDir, 'https://example.com', diff --git a/tests/test-numbered-prefixes.js b/tests/test-numbered-prefixes.js index a183547..fb5fec3 100644 --- a/tests/test-numbered-prefixes.js +++ b/tests/test-numbered-prefixes.js @@ -2,255 +2,139 @@ * Unit tests for numbered prefix route resolution * * Tests that the suffix-based matching correctly handles files and folders - * with numbered prefixes (e.g. "01-intro.md", "02-guide/"). + * with numbered prefixes (e.g. "01-intro.md", "02-guide/"), through the real + * processFilesWithPatterns route resolution. * * Run with: node tests/test-numbered-prefixes.js */ -console.log('Running numbered prefix route resolution tests...\n'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { processFilesWithPatterns } = require('../lib/processor'); -// Re-implement the core helpers locally for isolated unit testing -function findMatchingRoute(routesPaths, tail) { - const normalized = tail.toLowerCase().replace(/\/+$/, ''); - if (!normalized) return undefined; - const matches = routesPaths.filter((route) => { - const r = route.toLowerCase().replace(/\/+$/, ''); - return r === `/${normalized}` || r.endsWith(`/${normalized}`); - }); - if (matches.length <= 1) return matches[0]; - return matches.sort((a, b) => a.length - b.length)[0]; -} - -// Mirrors src/processor.ts — Docusaurus's DefaultNumberPrefixParser semantics: -// separator is one-or-more of [-_.], and version/date-like remainders are left -// intact (so "03--1.6.X" → "1.6.X" but "7.0-foo" stays "7.0-foo"). -const IGNORED_NUMBER_PREFIX_PATTERN = /^\d+[-_.]\d+/; -const NUMBER_PREFIX_PATTERN = /^(\d+)\s*[-_.]+\s*([^-_.\s].*)$/; - -function stripNumberPrefix(segment) { - if (IGNORED_NUMBER_PREFIX_PATTERN.test(segment)) { - return segment; - } - const match = NUMBER_PREFIX_PATTERN.exec(segment); - return match ? match[2] : segment; -} - -function removeNumberedPrefixes(pathStr) { - return pathStr.split('/').map(stripNumberPrefix).join('/'); -} - -function resolveWithCandidates(routesPaths, tail) { - const tails = new Set([tail]); - const stripped = removeNumberedPrefixes(tail); - if (stripped !== tail) tails.add(stripped); - - for (const t of tails) { - const match = findMatchingRoute(routesPaths, t); - if (match) return match; - } - return undefined; -} - -// Test 1: Exact match with numbered prefix in routesPaths -function testExactMatchWithNumberedPrefix() { - console.log('Test 1: Exact match when route retains numbered prefix'); - - const routesPaths = ['/docs/01-intro', '/docs/guide/01-start']; - - const resolved1 = findMatchingRoute(routesPaths, '01-intro'); - console.log( - resolved1 === '/docs/01-intro' - ? ' ✅ PASS: Matched "01-intro" to "/docs/01-intro"' - : ` ❌ FAIL: Expected "/docs/01-intro", got "${resolved1}"`, - ); - - const resolved2 = findMatchingRoute(routesPaths, 'guide/01-start'); - console.log( - resolved2 === '/docs/guide/01-start' - ? ' ✅ PASS: Matched "guide/01-start" to "/docs/guide/01-start"' - : ` ❌ FAIL: Expected "/docs/guide/01-start", got "${resolved2}"`, - ); - - console.log(''); -} - -// Test 2: Fallback to prefix removal when exact match not found -function testFallbackToPrefixRemoval() { - console.log('Test 2: Fallback to prefix removal when exact match not found'); - - const routesPaths = ['/docs/intro', '/docs/guide/start']; - - const resolved1 = resolveWithCandidates(routesPaths, '01-intro'); - console.log( - resolved1 === '/docs/intro' - ? ' ✅ PASS: "01-intro" fell back to "/docs/intro" via prefix removal' - : ` ❌ FAIL: Expected "/docs/intro", got "${resolved1}"`, - ); - - const resolved2 = resolveWithCandidates(routesPaths, '01-guide/01-start'); - console.log( - resolved2 === '/docs/guide/start' - ? ' ✅ PASS: "01-guide/01-start" fell back to "/docs/guide/start"' - : ` ❌ FAIL: Expected "/docs/guide/start", got "${resolved2}"`, - ); - - console.log(''); -} - -// Test 3: Exact match takes precedence over prefix removal -function testExactMatchPrecedence() { - console.log('Test 3: Exact match takes precedence over prefix removal'); +let passed = 0; +let failed = 0; +const tempDirs = []; - const routesPaths = ['/docs/01-intro', '/docs/intro']; - - // The original tail "01-intro" matches first, before stripping - const resolved = resolveWithCandidates(routesPaths, '01-intro'); - console.log( - resolved === '/docs/01-intro' - ? ' ✅ PASS: Exact match "/docs/01-intro" preferred over stripped "/docs/intro"' - : ` ❌ FAIL: Expected "/docs/01-intro", got "${resolved}"`, +/** + * Write `files` (paths relative to docs/) into a temp site, resolve them + * against `routesPaths`, and return each file's URL path keyed by its + * docs-relative path. + */ +async function resolveUrls(files, routesPaths) { + const siteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-numbered-prefixes-')); + tempDirs.push(siteDir); + const filePaths = files.map((rel) => { + const file = path.join(siteDir, 'docs', rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `---\ntitle: ${rel}\n---\n\nBody of ${rel}.\n`); + return file; + }); + const context = { + siteDir, + siteUrl: 'https://example.com', + docsDir: 'docs', + options: {}, + routesPaths, + }; + const docs = await processFilesWithPatterns(context, filePaths); + return Object.fromEntries( + docs.map((doc) => [doc.title, doc.url.replace('https://example.com', '')]), ); - - console.log(''); } -// Test 4: Complex nested numbered folders -function testComplexNestedNumberedFolders() { - console.log('Test 4: Complex nested numbered folders'); - - const routesPaths = ['/docs/guide/tutorials/advanced', '/docs/guide/tutorials']; - - const resolved1 = resolveWithCandidates(routesPaths, '01-guide/02-tutorials/03-advanced'); - console.log( - resolved1 === '/docs/guide/tutorials/advanced' - ? ' ✅ PASS: Three-level nested numbered folders resolved' - : ` ❌ FAIL: Expected "/docs/guide/tutorials/advanced", got "${resolved1}"`, - ); - - const resolved2 = resolveWithCandidates(routesPaths, '01-guide/02-tutorials'); - console.log( - resolved2 === '/docs/guide/tutorials' - ? ' ✅ PASS: Two-level nested numbered folders resolved' - : ` ❌ FAIL: Expected "/docs/guide/tutorials", got "${resolved2}"`, - ); - - console.log(''); +async function check(name, files, routesPaths, expected) { + const actual = await resolveUrls(files, routesPaths); + const ok = Object.entries(expected).every(([file, url]) => actual[file] === url); + if (ok) { + console.log(` ✅ PASS: ${name}`); + passed++; + } else { + console.log(` ❌ FAIL: ${name}`); + console.log(` expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + failed++; + } } -// Test 5: Mixed numbered and non-numbered segments -function testMixedNumberedSegments() { - console.log('Test 5: Mixed numbered and non-numbered segments'); +async function runAllTests() { + console.log('Running numbered prefix route resolution tests...\n'); - const routesPaths = ['/docs/api/getting-started', '/docs/guide/reference']; - - const resolved1 = resolveWithCandidates(routesPaths, 'api/01-getting-started'); - console.log( - resolved1 === '/docs/api/getting-started' - ? ' ✅ PASS: Non-numbered folder with numbered file resolved' - : ` ❌ FAIL: Expected "/docs/api/getting-started", got "${resolved1}"`, + await check( + 'Route that keeps the numbered prefix matches it', + ['01-intro.md', 'guide/01-start.md'], + ['/docs/01-intro', '/docs/guide/01-start'], + { '01-intro.md': '/docs/01-intro', 'guide/01-start.md': '/docs/guide/01-start' }, ); - const resolved2 = resolveWithCandidates(routesPaths, '01-guide/reference'); - console.log( - resolved2 === '/docs/guide/reference' - ? ' ✅ PASS: Numbered folder with non-numbered file resolved' - : ` ❌ FAIL: Expected "/docs/guide/reference", got "${resolved2}"`, + await check( + 'Numbered prefixes are stripped when no route keeps them', + ['01-intro.md', '01-guide/01-start.md'], + ['/docs/intro', '/docs/guide/start'], + { '01-intro.md': '/docs/intro', '01-guide/01-start.md': '/docs/guide/start' }, ); - console.log(''); -} - -// Test 6: Trailing slash handling -function testTrailingSlashHandling() { - console.log('Test 6: Trailing slash handling'); - - const routesPaths = ['/docs/intro/', '/docs/guide/']; - - const resolved1 = findMatchingRoute(routesPaths, 'intro'); - console.log( - resolved1 === '/docs/intro/' - ? ' ✅ PASS: Matched route with trailing slash' - : ` ❌ FAIL: Expected "/docs/intro/", got "${resolved1}"`, + await check( + 'The unstripped tail is matched before the stripped one', + ['01-intro.md'], + ['/docs/01-intro', '/docs/intro'], + { '01-intro.md': '/docs/01-intro' }, ); - const resolved2 = findMatchingRoute(routesPaths, 'guide/'); - console.log( - resolved2 === '/docs/guide/' - ? ' ✅ PASS: Matched tail with trailing slash to route with trailing slash' - : ` ❌ FAIL: Expected "/docs/guide/", got "${resolved2}"`, + await check( + 'Nested numbered folders resolve', + ['01-guide/02-tutorials/03-advanced.md', '01-guide/02-tutorials/index.md'], + ['/docs/guide/tutorials/advanced', '/docs/guide/tutorials'], + { + '01-guide/02-tutorials/03-advanced.md': '/docs/guide/tutorials/advanced', + '01-guide/02-tutorials/index.md': '/docs/guide/tutorials', + }, ); - console.log(''); -} - -// Test 7: Shortest match when multiple routes exist (versioned docs) -function testShortestMatchPreference() { - console.log('Test 7: Shortest match preferred (stable over versioned)'); - - const routesPaths = ['/intro', '/nightly/intro', '/v2/intro']; - - const resolved = findMatchingRoute(routesPaths, 'intro'); - console.log( - resolved === '/intro' - ? ' ✅ PASS: Shortest route "/intro" preferred over versioned' - : ` ❌ FAIL: Expected "/intro", got "${resolved}"`, + await check( + 'Mixed numbered and plain segments resolve', + ['api/01-getting-started.md', '01-guide/reference.md'], + ['/docs/api/getting-started', '/docs/guide/reference'], + { + 'api/01-getting-started.md': '/docs/api/getting-started', + '01-guide/reference.md': '/docs/guide/reference', + }, ); - console.log(''); -} - -// Test 8: Compound ordering prefix on version-like folder names -function testCompoundNumberPrefix() { - console.log('Test 8: Compound ordering prefix on version-like folder names'); - - const routesPaths = ['/docs/1.6.X/intro', '/docs/1.6.2/notes']; - - // "03--1.6.X" = ordering prefix "03-" + literal "-1.6.X"; both dashes are the - // separator, so the clean name is "1.6.X" (matching Docusaurus's route). - const resolved1 = resolveWithCandidates(routesPaths, '03--1.6.X/intro'); - console.log( - resolved1 === '/docs/1.6.X/intro' - ? ' ✅ PASS: "03--1.6.X/intro" resolved to "/docs/1.6.X/intro"' - : ` ❌ FAIL: Expected "/docs/1.6.X/intro", got "${resolved1}"`, + await check( + 'Routes with a trailing slash match', + ['01-intro.md'], + ['/docs/intro/', '/docs/guide/'], + { '01-intro.md': '/docs/intro/' }, ); - const resolved2 = resolveWithCandidates(routesPaths, '01--1.6.2/notes'); - console.log( - resolved2 === '/docs/1.6.2/notes' - ? ' ✅ PASS: "01--1.6.2/notes" resolved to "/docs/1.6.2/notes"' - : ` ❌ FAIL: Expected "/docs/1.6.2/notes", got "${resolved2}"`, + await check( + 'The shortest matching route is preferred', + ['intro.md'], + ['/docs/intro', '/docs/nightly/intro', '/docs/v2/intro'], + { 'intro.md': '/docs/intro' }, ); - // "7.0-foo" is a version-like name with no separate ordering prefix, so the - // ignored-prefix rule keeps it intact. - const kept = stripNumberPrefix('7.0-foo'); - console.log( - kept === '7.0-foo' - ? ' ✅ PASS: version-like "7.0-foo" preserved (not stripped)' - : ` ❌ FAIL: Expected "7.0-foo", got "${kept}"`, + // "03--1.6.X" is ordering prefix "03-" plus "-1.6.X"; Docusaurus's + // DefaultNumberPrefixParser treats both dashes as the separator. + await check( + 'Compound ordering prefixes on version-like folders resolve', + ['03--1.6.X/intro.md', '01--1.6.2/notes.md', '7.0-foo/page.md'], + ['/docs/1.6.X/intro', '/docs/1.6.2/notes', '/docs/7.0-foo/page'], + { + '03--1.6.X/intro.md': '/docs/1.6.X/intro', + '01--1.6.2/notes.md': '/docs/1.6.2/notes', + '7.0-foo/page.md': '/docs/7.0-foo/page', + }, ); - console.log(''); -} - -function runAllTests() { - console.log('='.repeat(70)); - console.log('Testing suffix-based numbered prefix route resolution'); - console.log('='.repeat(70)); - console.log(''); - - testExactMatchWithNumberedPrefix(); - testFallbackToPrefixRemoval(); - testExactMatchPrecedence(); - testComplexNestedNumberedFolders(); - testMixedNumberedSegments(); - testTrailingSlashHandling(); - testShortestMatchPreference(); - testCompoundNumberPrefix(); - - console.log('='.repeat(70)); - console.log('All numbered prefix tests completed!'); - console.log('='.repeat(70)); + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); } -runAllTests(); +runAllTests().catch((err) => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + console.error(err); + process.exit(1); +}); diff --git a/tests/test-path-bounds-checking.js b/tests/test-path-bounds-checking.js index e3ebacb..2fd3bda 100644 --- a/tests/test-path-bounds-checking.js +++ b/tests/test-path-bounds-checking.js @@ -1,90 +1,15 @@ /** - * Tests for array bounds checking in path operations + * Tests for array bounds checking in path operations: duplicate titles in + * llms-full.txt get a folder-name or numeric suffix from the real + * generateLLMFile, whatever the shape of the doc's path. * * Run with: node test-path-bounds-checking.js */ -// Import the ensureUniqueIdentifier utility -function ensureUniqueIdentifier(baseIdentifier, usedIdentifiers, suffixGenerator) { - let identifier = baseIdentifier; - let counter = 1; - - while (usedIdentifiers.has(identifier.toLowerCase())) { - counter++; - const suffix = suffixGenerator(counter, baseIdentifier); - identifier = `${baseIdentifier} ${suffix}`; - } - - usedIdentifiers.add(identifier.toLowerCase()); - return identifier; -} - -// Mock the generateLLMFile function from generator.ts with the fixed logic -function generateLLMFile( - docs, - outputPath, - fileTitle, - fileDescription, - includeFullContent, - version, -) { - console.log(`Generating file: ${outputPath}, version: ${version || 'undefined'}`); - const versionInfo = version ? `\n\nVersion: ${version}` : ''; - - if (includeFullContent) { - // Generate full content file with header deduplication - const usedHeaders = new Set(); - const fullContentSections = docs.map((doc) => { - // Check if content already starts with the same heading to avoid duplication - const trimmedContent = doc.content.trim(); - const firstLine = trimmedContent.split('\n')[0]; - - // Check if the first line is a heading that matches our title - const headingMatch = firstLine.match(/^#+\s+(.+)$/); - const firstHeadingText = headingMatch ? headingMatch[1].trim() : null; - - // Generate unique header using the utility function - const uniqueHeader = ensureUniqueIdentifier(doc.title, usedHeaders, (counter) => { - // Try to make it more descriptive by adding the file path info if available - if (doc.path && counter === 2) { - const pathParts = doc.path.split('/'); - // FIXED: Changed from > 1 to >= 2 to properly check array bounds - const folderName = pathParts.length >= 2 ? pathParts[pathParts.length - 2] : ''; - if (folderName) { - return `(${folderName.charAt(0).toUpperCase() + folderName.slice(1)})`; - } - } - return `(${counter})`; - }); - - if (firstHeadingText === doc.title) { - // Content already has the same heading, replace it with our unique header - const restOfContent = trimmedContent.split('\n').slice(1).join('\n'); - return `## ${uniqueHeader} - -${restOfContent}`; - } else { - // Content doesn't have the same heading, add our unique H2 header - return `## ${uniqueHeader} - -${doc.content}`; - } - }); - - const llmFileContent = `# ${fileTitle} - -> ${fileDescription}${versionInfo} - -This file contains all documentation content in a single document following the llmstxt.org standard. - -${fullContentSections.join('\n\n---\n\n')} -`; - - return llmFileContent; - } - - return ''; -} +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { generateLLMFile } = require('../lib/generator'); // Test cases for path bounds checking const testCases = [ @@ -224,52 +149,55 @@ const testCases = [ }, ]; -function runTests() { +async function runTests() { console.log('Running path bounds checking tests...\n'); let passCount = 0; - - testCases.forEach((test, index) => { - console.log(`Test ${index + 1}: ${test.name}`); - console.log(` ${test.description}`); - - try { - const output = generateLLMFile( - test.docs, - '/mock/output.txt', - 'Test Documentation', - 'Test description', - true, - 'test-version', - ); - - // Extract H2 headers from the output (document sections should be H2) - const headerMatches = output.match(/^## .+$/gm) || []; - const actualHeaders = headerMatches.map((h) => h.replace(/^## /, '')); - - console.log(` Expected headers: ${test.expectedHeaders.join(', ')}`); - console.log(` Actual headers: ${actualHeaders.join(', ')}`); - - // Check if headers match expected - const headersMatch = - actualHeaders.length === test.expectedHeaders.length && - actualHeaders.every((header, i) => header === test.expectedHeaders[i]); - - if (headersMatch) { - console.log(' ✅ PASS'); - passCount++; - } else { - console.log(' ❌ FAIL'); - console.log(` Expected: [${test.expectedHeaders.join(', ')}]`); - console.log(` Actual: [${actualHeaders.join(', ')}]`); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-path-bounds-')); + + try { + for (const [index, test] of testCases.entries()) { + console.log(`Test ${index + 1}: ${test.name}`); + console.log(` ${test.description}`); + + try { + const outputPath = path.join(outDir, `llms-full-${index}.txt`); + await generateLLMFile( + test.docs, + outputPath, + 'Test Documentation', + 'Test description', + true, + 'test-version', + ); + const output = fs.readFileSync(outputPath, 'utf8'); + + // Extract H2 headers from the output (document sections should be H2) + const headerMatches = output.match(/^## .+$/gm) || []; + const actualHeaders = headerMatches.map((h) => h.replace(/^## /, '')); + + console.log(` Expected headers: ${test.expectedHeaders.join(', ')}`); + console.log(` Actual headers: ${actualHeaders.join(', ')}`); + + const headersMatch = + actualHeaders.length === test.expectedHeaders.length && + actualHeaders.every((header, i) => header === test.expectedHeaders[i]); + + if (headersMatch) { + console.log(' ✅ PASS'); + passCount++; + } else { + console.log(' ❌ FAIL'); + } + } catch (error) { + console.log(' ❌ ERROR:', error.message); } - } catch (error) { - console.log(' ❌ ERROR:', error.message); - console.log(' Stack:', error.stack); - } - console.log(''); - }); + console.log(''); + } + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } console.log(`Results: ${passCount} of ${testCases.length} tests passed.`); @@ -281,5 +209,7 @@ function runTests() { } } -// Run the tests -runTests(); +runTests().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/test-path-transformation-ignore.js b/tests/test-path-transformation-ignore.js index f1e7769..ef49559 100644 --- a/tests/test-path-transformation-ignore.js +++ b/tests/test-path-transformation-ignore.js @@ -105,6 +105,17 @@ description: A test tutorial This is a test tutorial.`, ); + // A single options argument is ProcessFileOptions, so the transformation + // goes under `pathTransformation`. + let allCorrect = true; + const report = (url, expected) => { + const correct = url === expected; + if (!correct) allCorrect = false; + console.log(`URL: ${url}`); + console.log(`Expected: ${expected}`); + console.log(`${correct ? '✅' : '❌'} Correct: ${correct}\n`); + }; + // Test 1: Default URL generation console.log('Test 1: Default URL generation'); const result1 = await processMarkdownFile( @@ -113,9 +124,7 @@ This is a test tutorial.`, 'https://example.com', 'docs', ); - console.log(`URL: ${result1.url}`); - console.log(`Expected: https://example.com/docs/tutorials/draft`); - console.log(`✅ Correct: ${result1.url === 'https://example.com/docs/tutorials/draft'}\n`); + report(result1.url, 'https://example.com/docs/tutorials/draft'); // Test 2: With ignorePaths console.log('Test 2: With ignorePaths ["tutorials"]'); @@ -124,11 +133,9 @@ This is a test tutorial.`, path.join(TEST_DIR, 'docs'), 'https://example.com', 'docs', - { ignorePaths: ['tutorials'] }, + { pathTransformation: { ignorePaths: ['tutorials'] } }, ); - console.log(`URL: ${result2.url}`); - console.log(`Expected: https://example.com/docs/draft`); - console.log(`✅ Correct: ${result2.url === 'https://example.com/docs/draft'}\n`); + report(result2.url, 'https://example.com/docs/draft'); // Test 3: Ignore the path prefix itself console.log('Test 3: With ignorePaths ["docs"]'); @@ -137,11 +144,9 @@ This is a test tutorial.`, path.join(TEST_DIR, 'docs'), 'https://example.com', 'docs', - { ignorePaths: ['docs'] }, + { pathTransformation: { ignorePaths: ['docs'] } }, ); - console.log(`URL: ${result3.url}`); - console.log(`Expected: https://example.com/tutorials/draft`); - console.log(`✅ Correct: ${result3.url === 'https://example.com/tutorials/draft'}\n`); + report(result3.url, 'https://example.com/tutorials/draft'); // Test 4: Add paths console.log('Test 4: With addPaths ["reference"]'); @@ -150,13 +155,9 @@ This is a test tutorial.`, path.join(TEST_DIR, 'docs'), 'https://example.com', 'docs', - { addPaths: ['reference'] }, - ); - console.log(`URL: ${result4.url}`); - console.log(`Expected: https://example.com/docs/reference/tutorials/draft`); - console.log( - `✅ Correct: ${result4.url === 'https://example.com/docs/reference/tutorials/draft'}\n`, + { pathTransformation: { addPaths: ['reference'] } }, ); + report(result4.url, 'https://example.com/docs/reference/tutorials/draft'); // Test 5: Combined transformations console.log('Test 5: With ignorePaths ["tutorials"] and addPaths ["api"]'); @@ -166,20 +167,20 @@ This is a test tutorial.`, 'https://example.com', 'docs', { - ignorePaths: ['tutorials'], - addPaths: ['api'], + pathTransformation: { + ignorePaths: ['tutorials'], + addPaths: ['api'], + }, }, ); - console.log(`URL: ${result5.url}`); - console.log(`Expected: https://example.com/docs/api/draft`); - console.log(`✅ Correct: ${result5.url === 'https://example.com/docs/api/draft'}\n`); + report(result5.url, 'https://example.com/docs/api/draft'); // Cleanup if (fs.existsSync(TEST_DIR)) { fs.rmSync(TEST_DIR, { recursive: true }); } - return true; + return allCorrect; } // Run all tests diff --git a/tests/test-pattern-matching.js b/tests/test-pattern-matching.js index fe5ce16..df66095 100644 --- a/tests/test-pattern-matching.js +++ b/tests/test-pattern-matching.js @@ -6,13 +6,16 @@ */ const fs = require('fs'); +const os = require('os'); const path = require('path'); const pluginModule = require('../lib/index'); const plugin = pluginModule.default; -// Create test directory structure -const TEST_DIR = path.join(__dirname, '..', 'test-docs'); -const OUTPUT_DIR = path.join(__dirname, '..', 'test-output'); +// Create test directory structure in a temp site, so the tracked test-docs +// fixtures stay untouched. +const ROOT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-pattern-matching-')); +const TEST_DIR = path.join(ROOT_DIR, 'site'); +const OUTPUT_DIR = path.join(ROOT_DIR, 'output'); // Setup test docs structure async function setupTestDocs() { @@ -305,6 +308,7 @@ async function main() { await setupTestDocs(); await runTests(); const success = verifyResults(); + fs.rmSync(ROOT_DIR, { recursive: true, force: true }); if (success) { console.log('✅ All pattern matching tests passed successfully!'); @@ -314,6 +318,7 @@ async function main() { process.exit(1); } } catch (error) { + fs.rmSync(ROOT_DIR, { recursive: true, force: true }); console.error('Test failed with error:', error); process.exit(1); } diff --git a/tests/test-refactored-route-helpers.js b/tests/test-refactored-route-helpers.js deleted file mode 100644 index 4f4b4d4..0000000 --- a/tests/test-refactored-route-helpers.js +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Unit tests for refactored route resolution helper functions - * - * Tests suffix-based matching logic and path normalization used by - * resolveDocumentUrl. - */ - -function createMockContext(options = {}) { - return { - siteDir: options.siteDir || '/test', - siteUrl: options.siteUrl || 'https://example.com', - docsDir: options.docsDir || 'docs', - options: options.pluginOptions || {}, - routesPaths: options.routesPaths || undefined, - }; -} - -async function runTests() { - console.log('Running unit tests for refactored route resolution helpers...\n'); - - let passCount = 0; - let failCount = 0; - - function assert(condition, testName, message) { - if (condition) { - console.log(` ✓ PASS: ${testName}`); - passCount++; - } else { - console.log(` ✗ FAIL: ${testName}`); - if (message) { - console.log(` ${message}`); - } - failCount++; - } - } - - // Test 1: Suffix-based matching logic - console.log('Test Group 1: Suffix-based matching logic'); - { - function findMatchingRoute(routesPaths, tail) { - const normalized = tail.toLowerCase().replace(/\/+$/, ''); - if (!normalized) return undefined; - const matches = routesPaths.filter((route) => { - const r = route.toLowerCase().replace(/\/+$/, ''); - return r === `/${normalized}` || r.endsWith(`/${normalized}`); - }); - if (matches.length <= 1) return matches[0]; - return matches.sort((a, b) => a.length - b.length)[0]; - } - - assert( - findMatchingRoute(['/docs/simple'], 'simple') === '/docs/simple', - 'Simple suffix match', - 'Should match /docs/simple for tail "simple"', - ); - - assert( - findMatchingRoute(['/simple', '/nightly/simple'], 'simple') === '/simple', - 'Shortest match preferred', - 'Should prefer /simple over /nightly/simple', - ); - - assert( - findMatchingRoute([], 'simple') === undefined, - 'Empty routes returns undefined', - 'Should return undefined for empty routes', - ); - - assert( - findMatchingRoute(['/docs/other'], 'simple') === undefined, - 'No match returns undefined', - 'Should return undefined when no route matches', - ); - - assert( - findMatchingRoute(['/docs/test'], '') === undefined, - 'Empty tail returns undefined', - 'Should return undefined for empty tail', - ); - } - - // Test 2: Directory collapsing logic - console.log('\nTest Group 2: Directory collapsing'); - { - function collapseMatchingTrailingSegment(urlPath) { - const segments = urlPath.split('/'); - if (segments.length >= 2) { - const last = segments[segments.length - 1]; - const parent = segments[segments.length - 2]; - if (last.toLowerCase() === parent.toLowerCase()) { - return segments.slice(0, -1).join('/'); - } - } - return urlPath; - } - - assert( - collapseMatchingTrailingSegment('generics/generics') === 'generics', - 'Collapse matching trailing segment', - 'Should collapse "generics/generics" to "generics"', - ); - - assert( - collapseMatchingTrailingSegment('API/API') === 'API', - 'Case-insensitive collapse', - 'Should collapse case-insensitively', - ); - - assert( - collapseMatchingTrailingSegment('intro/overview') === 'intro/overview', - 'No collapse for non-matching', - 'Should not collapse when segments differ', - ); - - assert( - collapseMatchingTrailingSegment('single') === 'single', - 'Single segment unchanged', - 'Should return single segment as-is', - ); - } - - // Test 3: Numbered prefix removal - console.log('\nTest Group 3: Numbered prefix removal'); - { - function removeNumberedPrefixes(pathStr) { - return pathStr - .split('/') - .map((segment) => { - return segment.replace(/^\d+-/, ''); - }) - .join('/'); - } - - assert(removeNumberedPrefixes('01-intro') === 'intro', 'Single segment prefix removal'); - - assert( - removeNumberedPrefixes('01-category/02-file') === 'category/file', - 'Multiple segment prefix removal', - ); - - assert(removeNumberedPrefixes('clean/path') === 'clean/path', 'Clean path unchanged'); - - assert( - removeNumberedPrefixes('01-a/no-prefix/03-c') === 'a/no-prefix/c', - 'Mixed numbered and non-numbered segments', - ); - } - - // Test 4: Context without routesPaths - console.log('\nTest Group 4: Context without routesPaths'); - { - const context = createMockContext({}); - assert( - !context.routesPaths, - 'No routesPaths returns undefined', - 'Should return undefined when no routesPaths', - ); - - const contextEmpty = createMockContext({ routesPaths: [] }); - assert( - contextEmpty.routesPaths.length === 0, - 'Empty routesPaths handled', - 'Should handle empty array', - ); - } - - // Test 5: Suffix matching with trailing slashes - console.log('\nTest Group 5: Trailing slash handling in suffix matching'); - { - function findMatchingRoute(routesPaths, tail) { - const normalized = tail.toLowerCase().replace(/\/+$/, ''); - if (!normalized) return undefined; - const matches = routesPaths.filter((route) => { - const r = route.toLowerCase().replace(/\/+$/, ''); - return r === `/${normalized}` || r.endsWith(`/${normalized}`); - }); - if (matches.length <= 1) return matches[0]; - return matches.sort((a, b) => a.length - b.length)[0]; - } - - assert( - findMatchingRoute(['/docs/test/'], 'test') === '/docs/test/', - 'Matches route with trailing slash', - 'Should match routes that have trailing slashes', - ); - - assert( - findMatchingRoute(['/docs/test'], 'test/') === '/docs/test', - 'Matches tail with trailing slash', - 'Should match when tail has trailing slash', - ); - } - - // Test 6: Path normalization - console.log('\nTest Group 6: Path normalization'); - { - const windowsPath = 'docs\\subfolder\\file.md'; - const normalized = windowsPath.split('\\').join('/'); - assert(normalized === 'docs/subfolder/file.md', 'Windows path normalization'); - - const indexPath = 'docs/intro/index.md'; - const withoutExt = indexPath.replace(/\.mdx?$/, ''); - const withoutIndex = withoutExt.replace(/\/index$/, ''); - assert(withoutIndex === 'docs/intro', 'Index file handling'); - - const mdPath = 'docs/file.md'; - const mdxPath = 'docs/file.mdx'; - assert(mdPath.replace(/\.mdx?$/, '') === 'docs/file', '.md extension removal'); - assert(mdxPath.replace(/\.mdx?$/, '') === 'docs/file', '.mdx extension removal'); - } - - // Test 7: URL construction - console.log('\nTest Group 7: URL construction'); - { - const siteUrl = 'https://example.com'; - const resolvedPath = '/docs/test'; - - try { - const fullUrl = new URL(resolvedPath, siteUrl).toString(); - assert(fullUrl === 'https://example.com/docs/test', 'URL construction'); - } catch { - assert(false, 'URL construction', 'Should not throw error'); - } - - const siteUrlWithSlash = 'https://example.com/'; - try { - const fullUrl2 = new URL(resolvedPath, siteUrlWithSlash).toString(); - assert(fullUrl2 === 'https://example.com/docs/test', 'URL construction with trailing slash'); - } catch { - assert(false, 'URL construction with trailing slash', 'Should not throw error'); - } - } - - // Summary - console.log('\n' + '='.repeat(50)); - console.log(`Test Summary: ${passCount} passed, ${failCount} failed`); - console.log('='.repeat(50)); - - if (failCount > 0) { - process.exit(1); - } -} - -runTests().catch((err) => { - console.error('Unexpected error:', err); - process.exit(1); -}); diff --git a/tests/test-route-resolution-helpers.js b/tests/test-route-resolution-helpers.js index 8604e86..88c8bcb 100644 --- a/tests/test-route-resolution-helpers.js +++ b/tests/test-route-resolution-helpers.js @@ -5,14 +5,14 @@ */ const path = require('path'); -const fs = require('fs-extra'); +const fs = require('fs/promises'); const { processFilesWithPatterns } = require('../lib/processor'); const TEST_DIR = path.join(__dirname, 'route-helpers-test'); const DOCS_DIR = path.join(TEST_DIR, 'docs'); async function setupTestFiles() { - await fs.ensureDir(DOCS_DIR); + await fs.mkdir(DOCS_DIR, { recursive: true }); await fs.writeFile(path.join(DOCS_DIR, 'simple.md'), '# Simple\n\nSimple test file.'); @@ -20,7 +20,7 @@ async function setupTestFiles() { await fs.writeFile(path.join(DOCS_DIR, '02-another.md'), '# Another\n\nAnother numbered file.'); - await fs.ensureDir(path.join(DOCS_DIR, '01-category')); + await fs.mkdir(path.join(DOCS_DIR, '01-category'), { recursive: true }); await fs.writeFile( path.join(DOCS_DIR, '01-category', 'nested.md'), '# Nested\n\nNested file in numbered category.', @@ -33,9 +33,11 @@ async function setupTestFiles() { } async function cleanupTestFiles() { - await fs.remove(TEST_DIR); + await fs.rm(TEST_DIR, { recursive: true, force: true }); } +let failed = 0; + async function runTests() { console.log('Testing route resolution helper functions...\n'); @@ -63,6 +65,7 @@ async function runTests() { if (simpleDoc && simpleDoc.url === 'https://example.com/simple') { console.log(' ✓ PASS: Suffix match for simple.md'); } else { + failed++; console.log(' ✗ FAIL: Expected simple.md to resolve to /simple'); console.log(` Got: ${simpleDoc?.url}`); } @@ -70,6 +73,7 @@ async function runTests() { if (numberedDoc && numberedDoc.url === 'https://example.com/numbered') { console.log(' ✓ PASS: Suffix match for numbered file (prefix stripped)'); } else { + failed++; console.log(' ✗ FAIL: Expected 01-numbered.md to resolve to /numbered'); console.log(` Got: ${numberedDoc?.url}`); } @@ -94,6 +98,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/another') { console.log(' ✓ PASS: Numbered prefix stripped and matched'); } else { + failed++; console.log(' ✗ FAIL: Expected 02-another.md to resolve to /another'); console.log(` Got: ${doc?.url}`); } @@ -118,6 +123,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/category/nested') { console.log(' ✓ PASS: Nested numbered prefix stripped and matched'); } else { + failed++; console.log(' ✗ FAIL: Expected nested file to resolve to /category/nested'); console.log(` Got: ${doc?.url}`); } @@ -142,6 +148,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/category/double') { console.log(' ✓ PASS: Double numbered prefixes handled correctly'); } else { + failed++; console.log(' ✗ FAIL: Expected double numbered file to resolve correctly'); console.log(` Got: ${doc?.url}`); } @@ -165,6 +172,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/docs/simple') { console.log(' ✓ PASS: Fallback URL construction works'); } else { + failed++; console.log(' ✗ FAIL: Expected fallback URL to be /docs/simple'); console.log(` Got: ${doc?.url}`); } @@ -189,6 +197,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/another') { console.log(' ✓ PASS: Shortest route (stable) preferred over versioned'); } else { + failed++; console.log(' ✗ FAIL: Expected shortest match /another'); console.log(` Got: ${doc?.url}`); } @@ -201,7 +210,7 @@ async function runTests() { // producing the doubled /docs/docs/manual/get-started URL. console.log('\nTest 7: Issue #31 — docsDir stripping prevents docs/docs doubling'); { - await fs.ensureDir(path.join(DOCS_DIR, 'manual')); + await fs.mkdir(path.join(DOCS_DIR, 'manual'), { recursive: true }); await fs.writeFile( path.join(DOCS_DIR, 'manual', 'get-started.md'), '# Get Started\n\nGet started guide.', @@ -223,6 +232,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/manual/get-started') { console.log(' ✓ PASS: No docs/docs doubling — resolved to /manual/get-started'); } else { + failed++; console.log(' ✗ FAIL: Expected /manual/get-started (not /docs/docs/manual/get-started)'); console.log(` Got: ${doc?.url}`); } @@ -247,6 +257,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/simple/') { console.log(' ✓ PASS: Trailing-slash route matched and URL preserved'); } else { + failed++; console.log(' ✗ FAIL: Expected URL with trailing slash /simple/'); console.log(` Got: ${doc?.url}`); } @@ -255,7 +266,7 @@ async function runTests() { // Test 9: Directory collapsing (generics/generics.md -> /generics) console.log('\nTest 9: Directory collapsing — dir/dir.md resolves to /dir'); { - await fs.ensureDir(path.join(DOCS_DIR, 'generics')); + await fs.mkdir(path.join(DOCS_DIR, 'generics'), { recursive: true }); await fs.writeFile( path.join(DOCS_DIR, 'generics', 'generics.md'), '# Generics\n\nGenerics documentation.', @@ -277,6 +288,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/generics') { console.log(' ✓ PASS: generics/generics.md collapsed to /generics'); } else { + failed++; console.log(' ✗ FAIL: Expected /generics (not /generics/generics)'); console.log(` Got: ${doc?.url}`); } @@ -306,6 +318,7 @@ async function runTests() { if (doc && doc.url === 'https://example.com/python-to-mojo') { console.log(' ✓ PASS: Frontmatter id override resolved to /python-to-mojo'); } else { + failed++; console.log(' ✗ FAIL: Expected /python-to-mojo via frontmatter id override'); console.log(` Got: ${doc?.url}`); } @@ -335,12 +348,18 @@ async function runTests() { if (doc && doc.url === 'https://example.com/welcome') { console.log(' ✓ PASS: Frontmatter slug override resolved to /welcome'); } else { + failed++; console.log(' ✗ FAIL: Expected /welcome via frontmatter slug override'); console.log(` Got: ${doc?.url}`); } } - console.log('\n✓ All route resolution helper tests completed'); + if (failed > 0) { + console.log(`\n✗ ${failed} route resolution helper checks failed`); + process.exitCode = 1; + } else { + console.log('\n✓ All route resolution helper tests completed'); + } } catch (err) { console.error('Test failed with error:', err); process.exit(1); diff --git a/tests/test-versions-edges.js b/tests/test-versions-edges.js new file mode 100644 index 0000000..7abbf16 --- /dev/null +++ b/tests/test-versions-edges.js @@ -0,0 +1,199 @@ +/** + * Edge cases of versions mode: + * - `versions: 'auto'` labels each version the way Docusaurus does: the + * configured `versions..label`, else 'Next' for the current docs and + * the version name otherwise (plugin-content-docs lib/versions/version.js + * getVersionLabel) + * - with an explicit `versions` array where every version has a path prefix, + * the blog (listed in the first version) links its /blog/... routes: blog + * posts aren't versioned, so they match blog routes outside the version's + * route prefix. The cases use `trailingSlash: true` routes (Docusaurus + * applies the site's trailingSlash to every route, core + * server/plugins/routeConfig.js applyRouteTrailingSlash), where a matched + * route and a file-derived URL differ + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const docusaurusPluginLLMs = require('../lib/index.js').default; + +let passed = 0; +let failed = 0; + +function check(name, condition, detail) { + if (condition) { + console.log(` PASS: ${name}`); + passed++; + } else { + console.log(` FAIL: ${name}${detail ? `\n ${detail}` : ''}`); + failed++; + } +} + +function checkEqual(name, actual, expected) { + check( + name, + JSON.stringify(actual) === JSON.stringify(expected), + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); +} + +const tempDirs = []; +const page = (title, body, extra = '') => `---\ntitle: ${title}\n${extra}---\n\n${body}\n`; + +const FILES = { + 'versions.json': '["1.0"]', + 'docs/intro.md': page('Intro', 'Current intro.'), + 'versioned_docs/version-1.0/intro.md': page('Intro', 'Version 1.0 intro.'), + 'blog/2024-01-01-hello.md': page('Hello', 'Blog post.'), + 'blog/2024-02-03-world/index.md': page('World', 'Folder post.'), + 'blog/custom.md': page('Custom', 'Slugged post.', 'slug: my-custom-post\n'), +}; + +/** Routes as Docusaurus lists them with `trailingSlash: true`. */ +const withSlash = (routes) => routes.map((r) => (r.endsWith('/') ? r : `${r}/`)); + +const BLOG_ROUTES = [ + '/blog', + '/blog/2024/01/01/hello', + '/blog/2024/02/03/world', + '/blog/my-custom-post', + '/blog/archive', +]; + +const classicPreset = (docs) => ({ presets: [['classic', docs === undefined ? {} : { docs }]] }); + +async function runSite(options, routesPaths, siteConfig = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-versions-edges-')); + tempDirs.push(root); + const siteDir = path.join(root, 'site'); + const outDir = path.join(siteDir, 'build'); + fs.mkdirSync(outDir, { recursive: true }); + for (const [rel, content] of Object.entries(FILES)) { + const file = path.join(siteDir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } + const context = { + siteDir, + outDir, + siteConfig: { + title: 'T', + tagline: 'TL', + url: 'https://example.com', + baseUrl: '/', + ...siteConfig, + }, + }; + const plugin = docusaurusPluginLLMs(context, { + logLevel: 'quiet', + addMdExtension: false, + generateLLMsFullTxt: false, + ...options, + }); + await plugin.postBuild({ routesPaths, outDir }); + + const exists = (rel) => fs.existsSync(path.join(outDir, rel)); + const read = (rel) => (exists(rel) ? fs.readFileSync(path.join(outDir, rel), 'utf8') : ''); + const links = (rel = 'llms.txt') => + [...read(rel).matchAll(/^- \[[^\]]*\]\(([^)]*)\)/gm)] + .map((m) => m[1].replace('https://example.com', '')) + .sort(); + return { read, exists, links }; +} + +const versionLine = (txt) => (/^> Version: (.*)$/m.exec(txt) || [])[1]; + +async function testAutoDefaultLabels() { + console.log('\nversions: auto labels current "Next" and versioned docs by name'); + const site = await runSite( + { versions: 'auto' }, + ['/', '/docs/intro', '/docs/next/intro', ...BLOG_ROUTES], + classicPreset(), + ); + checkEqual('root (1.0) is labeled 1.0', versionLine(site.read('llms.txt')), '1.0'); + checkEqual('next/ (current) is labeled Next', versionLine(site.read('next/llms.txt')), 'Next'); +} + +async function testAutoLastVersionCurrentLabel() { + console.log('\nversions: auto with lastVersion current keeps the Next label'); + const site = await runSite( + { versions: 'auto' }, + ['/', '/docs/intro', '/docs/1.0/intro', ...BLOG_ROUTES], + classicPreset({ lastVersion: 'current', versions: { '1.0': { path: '1.0' } } }), + ); + checkEqual('root (current) is labeled Next', versionLine(site.read('llms.txt')), 'Next'); + checkEqual('1.0/ is labeled 1.0', versionLine(site.read('1.0/llms.txt')), '1.0'); +} + +async function testAutoConfiguredLabels() { + console.log('\nversions: auto uses configured labels'); + const site = await runSite( + { versions: 'auto' }, + ['/', '/docs/intro', '/docs/next/intro', ...BLOG_ROUTES], + classicPreset({ versions: { current: { label: 'Canary' }, '1.0': { label: 'One' } } }), + ); + checkEqual('root (1.0) uses its label', versionLine(site.read('llms.txt')), 'One'); + checkEqual('next/ (current) uses its label', versionLine(site.read('next/llms.txt')), 'Canary'); +} + +async function testExplicitAllPrefixedBlog() { + console.log('\nexplicit versions with no root version link blog routes'); + const site = await runSite( + { + includeBlog: true, + versions: [ + { name: 'nightly', docsDir: 'docs', path: 'nightly' }, + { name: 'stable', docsDir: 'versioned_docs/version-1.0', path: 'stable' }, + ], + }, + withSlash(['/', '/nightly/intro', '/stable/intro', ...BLOG_ROUTES]), + ); + checkEqual('nightly/llms.txt links the docs and blog routes', site.links('nightly/llms.txt'), [ + '/blog/2024/01/01/hello/', + '/blog/2024/02/03/world/', + '/blog/my-custom-post/', + '/nightly/intro/', + ]); + checkEqual('stable/llms.txt has no blog post', site.links('stable/llms.txt'), ['/stable/intro/']); +} + +async function testExplicitRoutePrefixBlog() { + console.log('\nexplicit versions with a routePrefix link blog routes'); + const site = await runSite( + { + includeBlog: true, + versions: [ + { name: 'nightly', docsDir: 'docs', path: 'nightly', routePrefix: 'docs/nightly' }, + { name: 'stable', docsDir: 'versioned_docs/version-1.0', path: '', routePrefix: 'docs' }, + ], + }, + withSlash(['/', '/docs/nightly/intro', '/docs/intro', ...BLOG_ROUTES]), + ); + checkEqual('root (stable) links the docs and blog routes', site.links(), [ + '/blog/2024/01/01/hello/', + '/blog/2024/02/03/world/', + '/blog/my-custom-post/', + '/docs/intro/', + ]); +} + +async function main() { + console.log('Testing versions edge cases...'); + await testAutoDefaultLabels(); + await testAutoLastVersionCurrentLabel(); + await testAutoConfiguredLabels(); + await testExplicitAllPrefixedBlog(); + await testExplicitRoutePrefixBlog(); + + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + console.error(err); + process.exit(1); +});