Product A
-Description of Product A with features.
-This is the homepage content.
- - - `)); - } - return Promise.resolve(Buffer.from('')); - }); + it('should generate valid JSON index', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); - await generateAiIndex('/test/project'); - - expect(mockWriteFile).toHaveBeenCalledWith( - path.join('/test/project', 'public', 'ai-index.json'), - expect.any(String) - ); - - const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - expect(writtenContent).toHaveProperty('version', '1.0.0'); - expect(writtenContent).toHaveProperty('generated'); - expect(writtenContent).toHaveProperty('baseUrl', 'https://example.com'); - expect(writtenContent.pages).toHaveLength(3); - expect(writtenContent.pages[0]).toMatchObject({ - url: 'https://example.com/', - title: 'Home', - description: 'Homepage' + expect(index).toHaveProperty('version', '1.0'); + expect(index).toHaveProperty('generated'); + expect(index.site).toEqual({ + title: 'Test Site', + description: 'A test site', + url: 'https://example.com', }); + expect(index.entries.length).toBeGreaterThan(0); }); - it('should generate AI index without content when extraction fails', async () => { - const mockReadFile = vi.mocked(fs.readFile); - const mockWriteFile = vi.mocked(fs.writeFile); - - mockReadFile.mockRejectedValue(new Error('File not found')); - - await generateAiIndex('/test/project'); - - expect(mockWriteFile).toHaveBeenCalled(); - const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - - expect(writtenContent.pages[0]).toMatchObject({ - url: 'https://example.com/', - title: 'Home', - description: 'Homepage' - }); - expect(writtenContent.pages[0].content).toBeUndefined(); + it('should create entries with required fields', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + + for (const entry of index.entries) { + expect(entry).toHaveProperty('id'); + expect(entry).toHaveProperty('url'); + expect(entry).toHaveProperty('title'); + expect(entry).toHaveProperty('content'); + expect(typeof entry.id).toBe('string'); + expect(entry.id.length).toBe(16); + } }); - it('should handle empty routes gracefully', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [], - baseUrl: 'https://example.com' - }); - - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateAiIndex('/test/project'); - - expect(mockWriteFile).toHaveBeenCalled(); - const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - expect(writtenContent.pages).toEqual([]); + it('should generate unique IDs for each entry', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + const ids = index.entries.map((e: any) => e.id); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).toBe(ids.length); + }); + + it('should extract keywords from content', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + + const homeEntry = index.entries.find((e: any) => e.url === 'https://example.com'); + expect(homeEntry?.keywords).toBeDefined(); + expect(Array.isArray(homeEntry?.keywords)).toBe(true); }); - it('should extract content from HTML correctly', async () => { - const mockReadFile = vi.mocked(fs.readFile); - const mockWriteFile = vi.mocked(fs.writeFile); - - mockReadFile.mockResolvedValue(Buffer.from(` - - - -We offer amazing solutions.
-Description of Product A with features.
-This is the homepage.
- - - `)); - } else if (pathStr.endsWith('/about.html')) { - return Promise.resolve(Buffer.from(` - - - -Learn more about our company.
- - - `)); - } else if (pathStr.endsWith('/blog/post1.html')) { - return Promise.resolve(Buffer.from(` - - - -This is an interesting article.
-Visible content here.
- - - - - `)); - - await generateLlmsFull('/test/project'); - - const content = mockWriteFile.mock.calls[0][1] as string; - - expect(content).toContain('Page Title'); - expect(content).toContain('Visible content here'); - expect(content).not.toContain('console.log'); - expect(content).not.toContain('color: red'); - expect(content).not.toContain('display: none'); + it('should include page content when pages have content', () => { + const config: ResolvedAeoConfig = { + ...baseConfig, + pages: [ + { pathname: '/', title: 'Home', content: 'Welcome to our site' }, + { pathname: '/about', title: 'About', description: 'About us', content: 'We are great' }, + ], + }; + + const result = generateLlmsFullTxt(config); + + expect(result).toContain('# Home'); + expect(result).toContain('Welcome to our site'); + expect(result).toContain('# About'); + expect(result).toContain('We are great'); + expect(result).toContain('URL: https://example.com'); + expect(result).toContain('URL: https://example.com/about'); }); - it('should include separator between pages', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('Content')); + it('should include separator between pages', () => { + const config: ResolvedAeoConfig = { + ...baseConfig, + pages: [ + { pathname: '/', title: 'Home', content: 'Home content' }, + { pathname: '/about', title: 'About', content: 'About content' }, + ], + }; + + const result = generateLlmsFullTxt(config); + const separatorCount = (result.match(/^---$/gm) || []).length; - await generateLlmsFull('/test/project'); - - const content = mockWriteFile.mock.calls[0][1] as string; - const separatorCount = (content.match(/---\n\n/g) || []).length; - expect(separatorCount).toBeGreaterThan(0); }); -}); \ No newline at end of file + + it('should include footer', () => { + const result = generateLlmsFullTxt(baseConfig); + + expect(result).toContain('## About This Document'); + expect(result).toContain('Generated by aeo.js'); + expect(result).toContain('https://aeojs.org'); + }); + + it('should include fallback content when no pages exist', () => { + const result = generateLlmsFullTxt(baseConfig); + + // Should still have the site title in the content + expect(result).toContain('# Test Project'); + expect(result).toContain('URL: https://example.com'); + }); +}); diff --git a/src/core/llms-txt.test.ts b/src/core/llms-txt.test.ts index 3596965..cbebc26 100644 --- a/src/core/llms-txt.test.ts +++ b/src/core/llms-txt.test.ts @@ -1,142 +1,108 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { generateLlmsTxt } from './llms-txt' -import fs from 'fs' -import path from 'path' -import type { ResolvedAeoConfig } from '../types' +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { generateLlmsTxt } from './llms-txt'; +import type { ResolvedAeoConfig } from '../types'; -vi.mock('fs') -vi.mock('path') +vi.mock('fs', () => ({ + readdirSync: vi.fn().mockReturnValue([]), + statSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('# Test\n\nContent'), + existsSync: vi.fn().mockReturnValue(false), +})); -describe('generateLlmsTxt', () => { - const mockFs = fs as any - const mockPath = path as any +const baseConfig: ResolvedAeoConfig = { + url: 'https://example.com', + title: 'Test Project', + description: 'A test project description', + contentDir: '/project/content', + outDir: 'public', + pages: [], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, +}; +describe('generateLlmsTxt', () => { beforeEach(() => { - vi.clearAllMocks() - mockPath.join.mockImplementation((...args: string[]) => args.join('/')) - mockPath.relative.mockImplementation((from: string, to: string) => - to.replace(from + '/', '') - ) - mockPath.extname.mockImplementation((file: string) => { - if (file.endsWith('.md')) return '.md' - if (file.endsWith('.mdx')) return '.mdx' - return '' - }) - - mockFs.readdirSync.mockReturnValue(['README.md', 'guide.md']) - mockFs.statSync.mockReturnValue({ - isDirectory: () => false, - isFile: () => true - }) - mockFs.readFileSync.mockReturnValue('# Test Title\n\nTest content\n\n## Section\n\nMore content') - }) - - const baseConfig: ResolvedAeoConfig = { - url: 'https://example.com', - title: 'Test Project', - description: 'A test project description', - contentDir: '/project/content', - outDir: 'public', - generators: { - robotsTxt: true, - llmsTxt: true, - llmsFullTxt: true, - rawMarkdown: true, - manifest: true, - sitemap: true, - aiIndex: true, - } - } + vi.clearAllMocks(); + }); it('should generate llms.txt with project title and description', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('# Test Project') - expect(result).toContain('> A test project description') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('# Test Project'); + expect(result).toContain('> A test project description'); + }); it('should include about section', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## About') - expect(result).toContain('This file provides a structured overview') - expect(result).toContain('optimized for consumption by Large Language Models') - }) - - it('should include documentation structure', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## Documentation Structure') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('## About'); + expect(result).toContain('This file provides a structured overview'); + expect(result).toContain('optimized for consumption by Large Language Models'); + }); it('should include quick links', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## Quick Links') - expect(result).toContain('Full Documentation: https://example.com/llms-full.txt') - expect(result).toContain('Documentation Manifest: https://example.com/docs.json') - expect(result).toContain('AI-Optimized Index: https://example.com/ai-index.json') - expect(result).toContain('Sitemap: https://example.com/sitemap.xml') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('## Quick Links'); + expect(result).toContain('Full Documentation: https://example.com/llms-full.txt'); + expect(result).toContain('Documentation Manifest: https://example.com/docs.json'); + expect(result).toContain('AI-Optimized Index: https://example.com/ai-index.json'); + expect(result).toContain('Sitemap: https://example.com/sitemap.xml'); + }); it('should include LLM instructions', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## For LLMs') - expect(result).toContain('To get the complete documentation in a single file') - expect(result).toContain('https://example.com/llms-full.txt') - expect(result).toContain('For structured access to individual pages') - expect(result).toContain('https://example.com/docs.json') - expect(result).toContain('For RAG (Retrieval Augmented Generation) systems') - expect(result).toContain('https://example.com/ai-index.json') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('## For LLMs'); + expect(result).toContain('https://example.com/llms-full.txt'); + expect(result).toContain('https://example.com/docs.json'); + expect(result).toContain('https://example.com/ai-index.json'); + }); it('should include footer', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('Generated by aeo.js') - expect(result).toContain('Learn more at https://aeojs.org') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('Generated by aeo.js'); + expect(result).toContain('Learn more at https://aeojs.org'); + }); it('should handle missing description', () => { + const config: ResolvedAeoConfig = { ...baseConfig, description: '' }; + const result = generateLlmsTxt(config); + + expect(result).toContain('# Test Project'); + // Should not have empty blockquote + expect(result).not.toContain('> \n'); + }); + + it('should include pages section when pages exist', () => { const config: ResolvedAeoConfig = { ...baseConfig, - description: undefined as any, - } - - const result = generateLlmsTxt(config) - - expect(result).not.toContain('> ') - expect(result).toContain('# Test Project') - }) - - it('should handle nested directory structure', () => { - mockFs.readdirSync.mockImplementation((dir: string) => { - if (dir === '/project/content') return ['docs', 'README.md'] - if (dir.includes('docs')) return ['guide.md', 'api.md'] - return [] - }) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('docs') && !path.includes('.md'), - isFile: () => path.includes('.md') - })) - - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('### Main Documentation') - expect(result).toContain('### docs') - }) - - it('should extract title from markdown content', () => { - mockFs.readFileSync.mockImplementation((file: string) => { - if (file.includes('README')) { - return '---\ntitle: Custom Title\ndescription: Custom desc\n---\n# Content Title\n\nContent' - } - return '# Regular Title\n\nContent' - }) - - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('[Custom Title]') - }) -}) \ No newline at end of file + pages: [ + { pathname: '/', title: 'Home' }, + { pathname: '/about', title: 'About Us' }, + ], + }; + const result = generateLlmsTxt(config); + + expect(result).toContain('## Pages'); + expect(result).toContain('Home'); + expect(result).toContain('About Us'); + }); +}); diff --git a/src/core/manifest.test.ts b/src/core/manifest.test.ts index 56bc75c..8e9590f 100644 --- a/src/core/manifest.test.ts +++ b/src/core/manifest.test.ts @@ -1,140 +1,121 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { generateManifest } from './manifest'; -import * as fs from 'fs/promises'; -import * as path from 'path'; - -vi.mock('fs/promises'); -vi.mock('./utils', () => ({ - resolveConfig: vi.fn().mockResolvedValue({ - routes: [ - { path: '/', title: 'Home', description: 'Homepage', keywords: ['home', 'main'] }, - { path: '/about', title: 'About Us', description: 'Learn about our company' }, - { path: '/products', title: 'Products' }, - { path: '/contact', title: 'Contact', description: 'Get in touch', keywords: ['contact', 'email', 'phone'] } - ], - baseUrl: 'https://example.com', - name: 'Example Site', - description: 'An example website' - }), - ensureDir: vi.fn().mockResolvedValue(undefined) +import type { ResolvedAeoConfig } from '../types'; + +vi.mock('fs', () => ({ + readdirSync: vi.fn().mockReturnValue([]), + statSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('# Test\n\nContent'), + existsSync: vi.fn().mockReturnValue(false), })); +const baseConfig: ResolvedAeoConfig = { + url: 'https://example.com', + title: 'Example Site', + description: 'An example website', + contentDir: '/project/content', + outDir: 'public', + pages: [ + { pathname: '/', title: 'Home', description: 'Homepage' }, + { pathname: '/about', title: 'About Us', description: 'Learn about our company' }, + { pathname: '/products', title: 'Products' }, + { pathname: '/contact', title: 'Contact', description: 'Get in touch' }, + ], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, +}; + describe('generateManifest', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('should generate docs.json manifest with all routes', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - expect(mockWriteFile).toHaveBeenCalledWith( - path.join('/test/project', 'public', 'docs.json'), - expect.any(String) - ); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - + it('should generate valid JSON manifest', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + expect(manifest).toHaveProperty('version', '1.0'); - expect(manifest).toHaveProperty('name', 'Example Site'); - expect(manifest).toHaveProperty('description', 'An example website'); - expect(manifest).toHaveProperty('baseUrl', 'https://example.com'); expect(manifest).toHaveProperty('generated'); + expect(manifest.site).toEqual({ + title: 'Example Site', + description: 'An example website', + url: 'https://example.com', + }); expect(manifest.documents).toHaveLength(4); }); - it('should format document entries correctly', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - - const homeDoc = manifest.documents[0]; + it('should format document entries correctly', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + + const homeDoc = manifest.documents.find((d: any) => d.url === 'https://example.com'); expect(homeDoc).toMatchObject({ - id: expect.stringMatching(/^[a-f0-9]{8}$/), - url: 'https://example.com/', + url: 'https://example.com', title: 'Home', description: 'Homepage', - keywords: ['home', 'main'] }); - - const aboutDoc = manifest.documents[1]; + + const aboutDoc = manifest.documents.find((d: any) => d.url.includes('/about')); expect(aboutDoc).toMatchObject({ - id: expect.stringMatching(/^[a-f0-9]{8}$/), url: 'https://example.com/about', title: 'About Us', description: 'Learn about our company', - keywords: [] - }); - - const productsDoc = manifest.documents[2]; - expect(productsDoc).toMatchObject({ - id: expect.stringMatching(/^[a-f0-9]{8}$/), - url: 'https://example.com/products', - title: 'Products', - description: '', - keywords: [] }); }); - it('should generate unique IDs for each document', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - const ids = manifest.documents.map((doc: any) => doc.id); - const uniqueIds = new Set(ids); - - expect(uniqueIds.size).toBe(ids.length); - }); + it('should handle empty pages array', () => { + const config: ResolvedAeoConfig = { ...baseConfig, pages: [] }; + const result = generateManifest(config); + const manifest = JSON.parse(result); - it('should handle empty routes array', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [], - baseUrl: 'https://example.com', - name: 'Empty Site', - description: 'A site with no pages' - }); - - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); expect(manifest.documents).toEqual([]); + expect(manifest.metadata.totalDocuments).toBe(0); }); - it('should use defaults for missing config fields', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [{ path: '/', title: 'Home' }], - baseUrl: 'https://example.com' + it('should include metadata', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + + expect(manifest.metadata).toMatchObject({ + totalDocuments: 4, + generator: 'aeo.js', + generatorUrl: 'https://aeojs.org', }); - - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - expect(manifest.name).toBe(''); - expect(manifest.description).toBe(''); }); - it('should include timestamp in generated field', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); + it('should include timestamp in generated field', () => { const beforeTime = new Date().toISOString(); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); const afterTime = new Date().toISOString(); - + expect(manifest.generated).toBeDefined(); - expect(new Date(manifest.generated).toISOString()).toBeGreaterThanOrEqual(beforeTime); - expect(new Date(manifest.generated).toISOString()).toBeLessThanOrEqual(afterTime); + expect(manifest.generated >= beforeTime).toBe(true); + expect(manifest.generated <= afterTime).toBe(true); + }); + + it('should sort documents by URL', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + const urls = manifest.documents.map((d: any) => d.url); + + const sorted = [...urls].sort(); + expect(urls).toEqual(sorted); }); -}); \ No newline at end of file +}); diff --git a/src/core/raw-markdown.test.ts b/src/core/raw-markdown.test.ts index 4e38de9..4519165 100644 --- a/src/core/raw-markdown.test.ts +++ b/src/core/raw-markdown.test.ts @@ -1,194 +1,185 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { copyRawMarkdown } from './raw-markdown'; -import { readdirSync, statSync, copyFileSync, mkdirSync } from 'fs'; -import * as path from 'path'; +import { copyRawMarkdown, generatePageMarkdownFiles } from './raw-markdown'; +import { readdirSync, statSync, copyFileSync, mkdirSync, writeFileSync } from 'fs'; import type { ResolvedAeoConfig } from '../types'; -vi.mock('fs'); +vi.mock('fs', () => ({ + readdirSync: vi.fn(), + statSync: vi.fn(), + copyFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn(), +})); + vi.mock('path', async () => { const actual = await vi.importActual('path'); return { ...actual, - join: vi.fn((...args) => args.filter(Boolean).join('/')), - relative: vi.fn((from, to) => to.replace(from + '/', '')), - extname: vi.fn((file) => { + join: vi.fn((...args: string[]) => args.filter(Boolean).join('/')), + relative: vi.fn((from: string, to: string) => to.replace(from + '/', '')), + extname: vi.fn((file: string) => { const match = file.match(/\.[^.]+$/); return match ? match[0] : ''; }), - dirname: vi.fn((file) => { + dirname: vi.fn((file: string) => { const parts = file.split('/'); parts.pop(); return parts.join('/'); - }) + }), }; }); +const createConfig = (overrides = {}): ResolvedAeoConfig => ({ + url: 'https://example.com', + title: 'Test Site', + description: 'Test description', + contentDir: 'content', + outDir: 'public/aeo', + pages: [], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, + ...overrides, +}); + describe('copyRawMarkdown', () => { const mockReaddirSync = vi.mocked(readdirSync); const mockStatSync = vi.mocked(statSync); const mockCopyFileSync = vi.mocked(copyFileSync); - const mockMkdirSync = vi.mocked(mkdirSync); beforeEach(() => { vi.clearAllMocks(); }); - const createConfig = (overrides = {}): ResolvedAeoConfig => ({ - url: 'https://example.com', - title: 'Test Site', - description: 'Test description', - contentDir: 'content', - outDir: 'public/aeo', - generators: { - robotsTxt: true, - llmsTxt: true, - llmsFullTxt: true, - rawMarkdown: true, - manifest: true, - sitemap: true, - aiIndex: true, - }, - ...overrides - }); + it('should copy markdown files from source to output directory', () => { + mockReaddirSync.mockReturnValue(['page1.md', 'page2.md', 'image.png'] as any); + mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false } as any); - it('should copy markdown files from source to public directory', () => { - mockReaddirSync.mockReturnValue(['page1.md', 'page2.md', 'image.png', 'subfolder'] as any); - - mockStatSync.mockImplementation((path) => { - const pathStr = path.toString(); - if (pathStr.endsWith('subfolder')) { - return { isFile: () => false, isDirectory: () => true } as any; - } - return { isFile: () => true, isDirectory: () => false } as any; - }); + const result = copyRawMarkdown(createConfig()); - const config = createConfig(); - const result = copyRawMarkdown(config); - expect(mockCopyFileSync).toHaveBeenCalledTimes(2); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/page1.md', - 'public/aeo/page1.md' - ); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/page2.md', - 'public/aeo/page2.md' - ); expect(result).toHaveLength(2); }); - it('should recursively copy markdown files from subdirectories', () => { + it('should skip non-markdown files', () => { + mockReaddirSync.mockReturnValue(['doc.md', 'script.js', 'style.css'] as any); + mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false } as any); + + const result = copyRawMarkdown(createConfig()); + + expect(mockCopyFileSync).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + + it('should handle missing directory gracefully', () => { + mockReaddirSync.mockImplementation(() => { throw new Error('ENOENT'); }); + + const result = copyRawMarkdown(createConfig()); + + expect(result).toEqual([]); + expect(mockCopyFileSync).not.toHaveBeenCalled(); + }); + + it('should recursively copy from subdirectories', () => { mockReaddirSync.mockImplementation((dirPath) => { const pathStr = dirPath.toString(); - if (pathStr === 'content') { - return ['docs', 'root.md'] as any; - } else if (pathStr.endsWith('docs')) { - return ['guide.md', 'api'] as any; - } else if (pathStr.endsWith('api')) { - return ['reference.md'] as any; - } + if (pathStr === 'content') return ['docs', 'root.md'] as any; + if (pathStr.endsWith('docs')) return ['guide.md'] as any; return []; }); - mockStatSync.mockImplementation((path) => { const pathStr = path.toString(); if (pathStr.includes('docs') && !pathStr.includes('.md')) { return { isFile: () => false, isDirectory: () => true } as any; } - if (pathStr.includes('api') && !pathStr.includes('.md')) { - return { isFile: () => false, isDirectory: () => true } as any; - } return { isFile: () => true, isDirectory: () => false } as any; }); - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/root.md', - 'public/aeo/root.md' - ); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/docs/guide.md', - 'public/aeo/docs/guide.md' - ); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/docs/api/reference.md', - 'public/aeo/docs/api/reference.md' - ); - - expect(mockMkdirSync).toHaveBeenCalledWith('public/aeo', expect.any(Object)); - expect(mockMkdirSync).toHaveBeenCalledWith('public/aeo/docs', expect.any(Object)); - expect(mockMkdirSync).toHaveBeenCalledWith('public/aeo/docs/api', expect.any(Object)); - expect(result).toHaveLength(3); + const result = copyRawMarkdown(createConfig()); + + expect(mockCopyFileSync).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); }); - it('should skip non-markdown files', () => { - mockReaddirSync.mockReturnValue(['document.md', 'script.js', 'style.css', 'data.json'] as any); - - mockStatSync.mockReturnValue({ - isFile: () => true, - isDirectory: () => false - } as any); - - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(mockCopyFileSync).toHaveBeenCalledTimes(1); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/document.md', - 'public/aeo/document.md' - ); + it('should handle copy errors for individual files', () => { + mockReaddirSync.mockReturnValue(['file1.md', 'file2.md'] as any); + mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false } as any); + mockCopyFileSync + .mockImplementationOnce(() => { throw new Error('Permission denied'); }) + .mockImplementationOnce(() => undefined); + + const result = copyRawMarkdown(createConfig()); + + expect(mockCopyFileSync).toHaveBeenCalledTimes(2); expect(result).toHaveLength(1); }); +}); + +describe('generatePageMarkdownFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - it('should handle missing markdown directory gracefully', () => { - mockReaddirSync.mockImplementation(() => { - throw new Error('ENOENT: Directory not found'); + it('should skip pages without content', () => { + const config = createConfig({ + pages: [ + { pathname: '/', title: 'Home' }, + { pathname: '/about', title: 'About' }, + ], }); - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(result).toEqual([]); - expect(mockCopyFileSync).not.toHaveBeenCalled(); + const result = generatePageMarkdownFiles(config); + + expect(result).toHaveLength(0); + expect(writeFileSync).not.toHaveBeenCalled(); }); - it('should use custom markdown directory from config', () => { - mockReaddirSync.mockReturnValue(['test.md'] as any); - - mockStatSync.mockReturnValue({ - isFile: () => true, - isDirectory: () => false - } as any); - - const config = createConfig({ contentDir: 'custom-docs' }); - const result = copyRawMarkdown(config); - - expect(mockReaddirSync).toHaveBeenCalledWith('custom-docs'); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'custom-docs/test.md', - 'public/aeo/test.md' - ); - expect(result).toHaveLength(1); + it('should generate .md files for pages with content', () => { + const config = createConfig({ + pages: [ + { pathname: '/', title: 'Home', content: 'Welcome to our site' }, + { pathname: '/about', title: 'About', description: 'About us', content: 'We are great' }, + ], + }); + + const result = generatePageMarkdownFiles(config); + + expect(result).toHaveLength(2); + expect(writeFileSync).toHaveBeenCalledTimes(2); }); - it('should handle copy errors for individual files', () => { - mockReaddirSync.mockReturnValue(['file1.md', 'file2.md'] as any); - - mockStatSync.mockReturnValue({ - isFile: () => true, - isDirectory: () => false - } as any); - - mockCopyFileSync - .mockImplementationOnce(() => { throw new Error('Permission denied'); }) - .mockImplementationOnce(() => undefined); + it('should include frontmatter in generated files', () => { + const config = createConfig({ + pages: [ + { pathname: '/about', title: 'About', description: 'About us', content: 'Content here' }, + ], + }); - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(mockCopyFileSync).toHaveBeenCalledTimes(2); - expect(result).toHaveLength(1); + generatePageMarkdownFiles(config); + + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(writtenContent).toContain('---'); + expect(writtenContent).toContain('title: "About"'); + expect(writtenContent).toContain('description: "About us"'); + expect(writtenContent).toContain('generated_by: aeo.js'); + expect(writtenContent).toContain('# About'); + expect(writtenContent).toContain('Content here'); }); -}); \ No newline at end of file +}); diff --git a/src/core/raw-markdown.ts b/src/core/raw-markdown.ts index 59e1f9d..9d2524a 100644 --- a/src/core/raw-markdown.ts +++ b/src/core/raw-markdown.ts @@ -64,9 +64,12 @@ export function generatePageMarkdownFiles(config: ResolvedAeoConfig): GeneratedM const pages = config.pages || []; for (const page of pages) { - // Use site title as fallback for pages without a title + // Only generate .md files for pages that have actual content. + // Pages discovered from filenames (dev mode) only have pathname/title + // but no body content — those are useless as standalone markdown files. + if (!page.content) continue; + const pageTitle = page.title || (page.pathname === '/' ? config.title : undefined); - if (!page.content && !pageTitle) continue; let filename: string; if (page.pathname === '/') { diff --git a/src/core/robots.test.ts b/src/core/robots.test.ts index 9c67690..ac182b5 100644 --- a/src/core/robots.test.ts +++ b/src/core/robots.test.ts @@ -9,6 +9,7 @@ describe('generateRobotsTxt', () => { description: 'Test description', contentDir: 'content', outDir: 'public', + pages: [], generators: { robotsTxt: true, llmsTxt: true, @@ -17,7 +18,16 @@ describe('generateRobotsTxt', () => { manifest: true, sitemap: true, aiIndex: true, - } + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, } it('should generate robots.txt with AI crawler rules', () => { diff --git a/src/core/sitemap.test.ts b/src/core/sitemap.test.ts index 713bf31..565a8ec 100644 --- a/src/core/sitemap.test.ts +++ b/src/core/sitemap.test.ts @@ -38,6 +38,7 @@ describe('generateSitemap', () => { description: 'Test description', contentDir: '/test/content', outDir: 'public', + pages: [], generators: { robotsTxt: true, llmsTxt: true, @@ -46,7 +47,16 @@ describe('generateSitemap', () => { manifest: true, sitemap: true, aiIndex: true, - } + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, }; it('should generate sitemap.xml with all routes', () => { diff --git a/src/core/utils.test.ts b/src/core/utils.test.ts index b3439a5..62c1a48 100644 --- a/src/core/utils.test.ts +++ b/src/core/utils.test.ts @@ -1,176 +1,125 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { - resolveConfig, - getAllMarkdownFiles, - getProjectStructure, - generateHash, - ensureDirectoryExists -} from './utils' -import fs from 'fs' -import path from 'path' -import crypto from 'crypto' - -vi.mock('fs') -vi.mock('path') -vi.mock('crypto') +import { describe, it, expect, vi } from 'vitest'; +import { + resolveConfig, + parseFrontmatter, + bumpHeadings, + extractTitle, +} from './utils'; + +vi.mock('./detect', () => ({ + detectFramework: vi.fn().mockReturnValue({ + framework: 'unknown', + contentDir: 'src', + outDir: 'dist', + }), +})); describe('utils', () => { - const mockFs = fs as any - const mockPath = path as any - const mockCrypto = crypto as any - - beforeEach(() => { - vi.clearAllMocks() - mockPath.join.mockImplementation((...args: string[]) => args.join('/')) - mockPath.resolve.mockImplementation((p: string) => `/absolute${p}`) - mockPath.relative.mockImplementation((from: string, to: string) => to.replace(from, '')) - mockPath.dirname.mockImplementation((p: string) => { - const parts = p.split('/') - parts.pop() - return parts.join('/') - }) - }) - describe('resolveConfig', () => { - it('should merge default config with user config', () => { - const userConfig = { - output: 'custom-output', - baseUrl: 'https://custom.com', - include: ['*.mdx'] - } - - const result = resolveConfig(userConfig) - - expect(result.output).toBe('custom-output') - expect(result.baseUrl).toBe('https://custom.com') - expect(result.include).toContain('*.mdx') - expect(result.include).toContain('**/*.md') - expect(result.exclude).toContain('**/node_modules/**') - }) - - it('should use default config when no user config provided', () => { - const result = resolveConfig() - - expect(result.output).toBe('public/aeo') - expect(result.baseUrl).toBe('') - expect(result.include).toEqual(['**/*.md', '**/*.mdx']) - expect(result.exclude).toContain('**/node_modules/**') - }) - - it('should handle partial user config', () => { - const userConfig = { - baseUrl: 'https://example.com' - } - - const result = resolveConfig(userConfig) - - expect(result.output).toBe('public/aeo') - expect(result.baseUrl).toBe('https://example.com') - }) - }) - - describe('getAllMarkdownFiles', () => { - it('should find all markdown files matching patterns', () => { - mockFs.readdirSync.mockReturnValue(['file1.md', 'file2.mdx', 'file3.txt']) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('subdir'), - isFile: () => !path.includes('subdir') - })) - mockFs.existsSync.mockReturnValue(true) - - const files = getAllMarkdownFiles('/root', ['*.md', '*.mdx'], []) - - expect(files.length).toBeGreaterThan(0) - }) - - it('should exclude files matching exclude patterns', () => { - mockFs.readdirSync.mockReturnValue(['file1.md', 'node_modules', 'file2.md']) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('node_modules'), - isFile: () => !path.includes('node_modules') - })) - - const files = getAllMarkdownFiles('/root', ['**/*.md'], ['**/node_modules/**']) - - expect(files.every(f => !f.includes('node_modules'))).toBe(true) - }) - }) - - describe('getProjectStructure', () => { - it('should generate project structure tree', () => { - mockFs.readdirSync.mockImplementation((dir: string) => { - if (dir === '/root') return ['src', 'package.json'] - if (dir.includes('src')) return ['index.js', 'utils.js'] - return [] - }) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('src') && !path.includes('.'), - isFile: () => path.includes('.') - })) - - const structure = getProjectStructure('/root') - - expect(structure).toContain('src/') - expect(structure).toContain('package.json') - }) - - it('should respect max depth limit', () => { - mockFs.readdirSync.mockReturnValue(['deep']) - mockFs.statSync.mockReturnValue({ isDirectory: () => true, isFile: () => false }) - - const structure = getProjectStructure('/root', 2) - const lines = structure.split('\n') - - expect(lines.length).toBeLessThanOrEqual(10) - }) - }) - - describe('generateHash', () => { - it('should generate consistent hash for same content', () => { - const mockHash = { - update: vi.fn().mockReturnThis(), - digest: vi.fn().mockReturnValue('abc123') - } - mockCrypto.createHash.mockReturnValue(mockHash) - - const hash1 = generateHash('test content') - const hash2 = generateHash('test content') - - expect(hash1).toBe(hash2) - expect(hash1).toBe('abc123') - }) - - it('should generate different hash for different content', () => { - let callCount = 0 - const mockHash = { - update: vi.fn().mockReturnThis(), - digest: vi.fn(() => `hash${++callCount}`) - } - mockCrypto.createHash.mockReturnValue(mockHash) - - const hash1 = generateHash('content1') - const hash2 = generateHash('content2') - - expect(hash1).not.toBe(hash2) - }) - }) - - describe('ensureDirectoryExists', () => { - it('should create directory if it does not exist', () => { - mockFs.existsSync.mockReturnValue(false) - mockFs.mkdirSync.mockReturnValue(undefined) - - ensureDirectoryExists('/new/path') - - expect(mockFs.mkdirSync).toHaveBeenCalledWith('/new/path', { recursive: true }) - }) - - it('should not create directory if it already exists', () => { - mockFs.existsSync.mockReturnValue(true) - - ensureDirectoryExists('/existing/path') - - expect(mockFs.mkdirSync).not.toHaveBeenCalled() - }) - }) -}) \ No newline at end of file + it('should return default config when no user config provided', () => { + const result = resolveConfig(); + + expect(result.title).toBe('My Site'); + expect(result.description).toBe(''); + expect(result.url).toBe('https://example.com'); + expect(result.generators.robotsTxt).toBe(true); + expect(result.generators.llmsTxt).toBe(true); + expect(result.widget.enabled).toBe(true); + expect(result.widget.position).toBe('bottom-right'); + }); + + it('should merge user config with defaults', () => { + const result = resolveConfig({ + title: 'Custom Title', + url: 'https://custom.com', + generators: { sitemap: false }, + }); + + expect(result.title).toBe('Custom Title'); + expect(result.url).toBe('https://custom.com'); + expect(result.generators.sitemap).toBe(false); + expect(result.generators.robotsTxt).toBe(true); + }); + + it('should handle partial widget config', () => { + const result = resolveConfig({ + widget: { + position: 'top-left', + theme: { accent: '#FF0000' }, + }, + }); + + expect(result.widget.position).toBe('top-left'); + expect(result.widget.theme.accent).toBe('#FF0000'); + expect(result.widget.theme.background).toBe('rgba(18, 18, 24, 0.9)'); + }); + + it('should resolve robots config', () => { + const result = resolveConfig({ + robots: { disallow: ['/admin'], crawlDelay: 5 }, + }); + + expect(result.robots.disallow).toEqual(['/admin']); + expect(result.robots.crawlDelay).toBe(5); + expect(result.robots.allow).toEqual(['/']); + }); + }); + + describe('parseFrontmatter', () => { + it('should extract frontmatter from markdown', () => { + const input = '---\ntitle: My Title\ndescription: My Desc\n---\n# Content'; + const result = parseFrontmatter(input); + + expect(result.frontmatter.title).toBe('My Title'); + expect(result.frontmatter.description).toBe('My Desc'); + expect(result.content).toContain('# Content'); + }); + + it('should return empty frontmatter when none exists', () => { + const input = '# Just Content\nNo frontmatter here'; + const result = parseFrontmatter(input); + + expect(result.frontmatter).toEqual({}); + expect(result.content).toBe(input); + }); + + it('should handle quoted values', () => { + const input = '---\ntitle: "Quoted Title"\n---\nContent'; + const result = parseFrontmatter(input); + + expect(result.frontmatter.title).toBe('Quoted Title'); + }); + }); + + describe('bumpHeadings', () => { + it('should increase heading levels by specified amount', () => { + const input = '# H1\n## H2\n### H3'; + const result = bumpHeadings(input, 1); + + expect(result).toContain('## H1'); + expect(result).toContain('### H2'); + expect(result).toContain('#### H3'); + }); + + it('should cap at h6', () => { + const input = '###### H6'; + const result = bumpHeadings(input, 1); + + expect(result).toContain('###### H6'); + }); + }); + + describe('extractTitle', () => { + it('should extract h1 title', () => { + expect(extractTitle('# My Title\nContent')).toBe('My Title'); + }); + + it('should fall back to h2', () => { + expect(extractTitle('## Sub Title\nContent')).toBe('Sub Title'); + }); + + it('should fall back to first line', () => { + expect(extractTitle('Some text\nMore text')).toBe('Some text'); + }); + }); +}); diff --git a/src/core/utils.ts b/src/core/utils.ts index 2f014dc..e339894 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -4,9 +4,31 @@ import type { AeoConfig, ResolvedAeoConfig, MarkdownFile } from '../types'; import { detectFramework } from './detect'; import { minimatch } from 'minimatch'; +export function validateConfig(config: AeoConfig): string[] { + const warnings: string[] = []; + + if (config.url && !/^https?:\/\//.test(config.url)) { + warnings.push(`url "${config.url}" should start with http:// or https://`); + } + + if (config.url === 'https://example.com') { + warnings.push('url is set to the default "https://example.com" — set your actual site URL'); + } + + if (!config.title) { + warnings.push('title is not set — your generated files will use "My Site"'); + } + + if (config.robots?.crawlDelay && config.robots.crawlDelay < 0) { + warnings.push('robots.crawlDelay should be a positive number'); + } + + return warnings; +} + export function resolveConfig(config: AeoConfig = {}): ResolvedAeoConfig { const frameworkInfo = detectFramework(); - + return { title: config.title || 'My Site', description: config.description || '', diff --git a/src/index.ts b/src/index.ts index edd5215..6f30b58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import type { AeoConfig } from './types'; -export const VERSION = '0.0.1'; +export const VERSION = '0.0.2'; export function defineConfig(config: AeoConfig): AeoConfig { return config; @@ -23,4 +23,4 @@ export type { // Export core functions export { detectFramework } from './core/detect'; export { generateAEOFiles as generateAll, generateAEOFiles } from './core/generate'; -export { resolveConfig } from './core/utils'; \ No newline at end of file +export { resolveConfig, validateConfig } from './core/utils'; \ No newline at end of file diff --git a/src/plugins/angular.test.ts b/src/plugins/angular.test.ts new file mode 100644 index 0000000..2feccea --- /dev/null +++ b/src/plugins/angular.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { postBuild, generate, getWidgetScript } from './angular'; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'fs'; + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readdirSync: vi.fn().mockReturnValue([]), + readFileSync: vi.fn().mockReturnValue(''), + statSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock('../core/generate', () => ({ + generateAEOFiles: vi.fn().mockResolvedValue({ files: ['robots.txt'], errors: [] }), +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockReaddirSync = vi.mocked(readdirSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockStatSync = vi.mocked(statSync); +const mockWriteFileSync = vi.mocked(writeFileSync); + +describe('Angular plugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + mockReaddirSync.mockReturnValue([]); + }); + + describe('getWidgetScript', () => { + it('should return script tag with widget config', () => { + const script = getWidgetScript({ + title: 'My App', + url: 'https://myapp.com', + }); + + expect(script).toContain('