diff --git a/.ralph/activity.md b/.ralph/activity.md index e8b5555..8add941 100644 --- a/.ralph/activity.md +++ b/.ralph/activity.md @@ -94,3 +94,18 @@ - ❌ npm run build --- +### Iteration 1 - 2026-02-20T17:56:38.440Z + +**Status:** 🔶 Partial +**Summary:** The core architecture setup section already covers the scaffolding that was needed for this task. Le +**Duration:** 4m 15s +**Cost:** $0.627 (42,399 tokens) + +--- +### Iteration 2 - 2026-02-20T18:00:53.634Z + +**Status:** 🚫 Blocked +**Summary:** Permission denied +**Duration:** 5m 0s + +--- diff --git a/.ralph/iteration-log.md b/.ralph/iteration-log.md index 4068feb..dcc6017 100644 --- a/.ralph/iteration-log.md +++ b/.ralph/iteration-log.md @@ -2,3 +2,7 @@ - Status: validation passed - Changes: yes - Summary: Process timed out +## Iteration 1 +- Status: validation passed +- Changes: yes +- Summary: The core architecture setup section already covers the scaffolding that was needed for this task. Le diff --git a/package.json b/package.json index 75d1dbd..9a87768 100644 --- a/package.json +++ b/package.json @@ -12,49 +12,44 @@ "types": "./dist/index.d.ts" }, "./vite": { - "import": "./dist/plugins/vite.mjs", - "require": "./dist/plugins/vite.js", - "types": "./dist/plugins/vite.d.ts" + "import": "./dist/vite.mjs", + "require": "./dist/vite.js", + "types": "./dist/vite.d.ts" }, "./next": { - "import": "./dist/plugins/next.mjs", - "require": "./dist/plugins/next.js", - "types": "./dist/plugins/next.d.ts" + "import": "./dist/next.mjs", + "require": "./dist/next.js", + "types": "./dist/next.d.ts" }, "./astro": { - "import": "./dist/plugins/astro.mjs", - "require": "./dist/plugins/astro.js", - "types": "./dist/plugins/astro.d.ts" + "import": "./dist/astro.mjs", + "require": "./dist/astro.js", + "types": "./dist/astro.d.ts" }, "./nuxt": { - "import": "./dist/plugins/nuxt.mjs", - "require": "./dist/plugins/nuxt.js", - "types": "./dist/plugins/nuxt.d.ts" + "import": "./dist/nuxt.mjs", + "require": "./dist/nuxt.js", + "types": "./dist/nuxt.d.ts" }, "./webpack": { - "import": "./dist/plugins/webpack.mjs", - "require": "./dist/plugins/webpack.js", - "types": "./dist/plugins/webpack.d.ts" + "import": "./dist/webpack.mjs", + "require": "./dist/webpack.js", + "types": "./dist/webpack.d.ts" }, "./react": { - "import": "./dist/widget/react.mjs", - "require": "./dist/widget/react.js", - "types": "./dist/widget/react.d.ts" + "import": "./dist/react.mjs", + "require": "./dist/react.js", + "types": "./dist/react.d.ts" }, "./vue": { - "import": "./dist/widget/vue.mjs", - "require": "./dist/widget/vue.js", - "types": "./dist/widget/vue.d.ts" - }, - "./svelte": { - "import": "./dist/widget/svelte.mjs", - "require": "./dist/widget/svelte.js", - "types": "./dist/widget/svelte.d.ts" + "import": "./dist/vue.mjs", + "require": "./dist/vue.js", + "types": "./dist/vue.d.ts" }, "./widget": { - "import": "./dist/widget/core.mjs", - "require": "./dist/widget/core.js", - "types": "./dist/widget/core.d.ts" + "import": "./dist/widget.mjs", + "require": "./dist/widget.js", + "types": "./dist/widget.d.ts" } }, "files": [ @@ -64,6 +59,7 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest", + "lint": "tsc --noEmit", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/src/core/ai-index.test.ts b/src/core/ai-index.test.ts new file mode 100644 index 0000000..6d076f5 --- /dev/null +++ b/src/core/ai-index.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { generateAiIndex } from './ai-index'; +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' }, + { path: '/about', title: 'About', description: 'About us' }, + { path: '/contact', title: 'Contact' } + ], + baseUrl: 'https://example.com' + }), + ensureDir: vi.fn().mockResolvedValue(undefined) +})); + +describe('generateAiIndex', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should generate AI index with content extraction', async () => { + const mockReadFile = vi.mocked(fs.readFile); + const mockWriteFile = vi.mocked(fs.writeFile); + + mockReadFile.mockImplementation((filePath) => { + if (filePath.toString().endsWith('/public/index.html')) { + return Promise.resolve(Buffer.from(` + + + Test Page + +

Welcome

+

This is the homepage content.

+ + + `)); + } + return Promise.resolve(Buffer.from('')); + }); + + 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' + }); + }); + + 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 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 extract content from HTML correctly', async () => { + const mockReadFile = vi.mocked(fs.readFile); + const mockWriteFile = vi.mocked(fs.writeFile); + + mockReadFile.mockResolvedValue(Buffer.from(` + + + + Product Page + + + + +
+

Our Products

+

We offer amazing solutions.

+
+

Product A

+

Description of Product A with features.

+
+
+ + + + `)); + + await generateAiIndex('/test/project'); + + const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + const page = writtenContent.pages[0]; + + expect(page.content).toContain('Our Products'); + expect(page.content).toContain('amazing solutions'); + expect(page.content).toContain('Product A'); + expect(page.content).not.toContain('Navigation links'); + expect(page.content).not.toContain('Footer content'); + }); +}); \ No newline at end of file diff --git a/src/core/ai-index.ts b/src/core/ai-index.ts index 0a7876b..495c6de 100644 --- a/src/core/ai-index.ts +++ b/src/core/ai-index.ts @@ -1,7 +1,7 @@ import { readdirSync, readFileSync, statSync } from 'fs'; import { join, relative, extname } from 'path'; import { createHash } from 'crypto'; -import type { ResolvedConfig, AIIndexEntry } from '../types'; +import type { ResolvedAeoConfig, AIIndexEntry } from '../types'; import { parseFrontmatter, extractTitle } from './utils'; function extractKeywords(content: string): string[] { @@ -44,7 +44,7 @@ function chunkContent(content: string, maxLength: number = 2000): string[] { return chunks; } -function collectAIIndexEntries(dir: string, config: ResolvedConfig, base: string = dir): AIIndexEntry[] { +function collectAIIndexEntries(dir: string, config: ResolvedAeoConfig, base: string = dir): AIIndexEntry[] { const entries: AIIndexEntry[] = []; try { @@ -97,7 +97,7 @@ function collectAIIndexEntries(dir: string, config: ResolvedConfig, base: string return entries; } -export function generateAIIndex(config: ResolvedConfig): string { +export function generateAIIndex(config: ResolvedAeoConfig): string { const entries = collectAIIndexEntries(config.contentDir, config); const index = { diff --git a/src/core/detect.ts b/src/core/detect.ts index 2fe8e64..da0279c 100644 --- a/src/core/detect.ts +++ b/src/core/detect.ts @@ -1,4 +1,4 @@ -import type { Framework, FrameworkInfo } from '../types'; +import type { FrameworkType, FrameworkInfo } from '../types'; import { readPackageJson } from './utils'; export function detectFramework(projectRoot: string = process.cwd()): FrameworkInfo { diff --git a/src/core/generate-wrapper.test.ts b/src/core/generate-wrapper.test.ts new file mode 100644 index 0000000..3b12385 --- /dev/null +++ b/src/core/generate-wrapper.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { generateAllWrapper } from './generate-wrapper'; + +// Mock all generator functions +vi.mock('./robots', () => ({ + generateRobots: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('./sitemap', () => ({ + generateSitemap: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('./llms-txt', () => ({ + generateLlmsTxt: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('./llms-full', () => ({ + generateLlmsFull: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('./manifest', () => ({ + generateManifest: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('./ai-index', () => ({ + generateAiIndex: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('./raw-markdown', () => ({ + copyRawMarkdown: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('./utils', () => ({ + resolveConfig: vi.fn().mockResolvedValue({ + routes: [{ path: '/', title: 'Home' }], + baseUrl: 'https://example.com', + generators: { + robots: true, + sitemap: true, + llmsTxt: true, + llmsFull: true, + manifest: true, + aiIndex: true, + rawMarkdown: true + } + }) +})); + +describe('generateAllWrapper', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should call all enabled generators', async () => { + const { generateRobots } = await import('./robots'); + const { generateSitemap } = await import('./sitemap'); + const { generateLlmsTxt } = await import('./llms-txt'); + const { generateLlmsFull } = await import('./llms-full'); + const { generateManifest } = await import('./manifest'); + const { generateAiIndex } = await import('./ai-index'); + const { copyRawMarkdown } = await import('./raw-markdown'); + + await generateAllWrapper('/test/project'); + + expect(generateRobots).toHaveBeenCalledWith('/test/project'); + expect(generateSitemap).toHaveBeenCalledWith('/test/project'); + expect(generateLlmsTxt).toHaveBeenCalledWith('/test/project'); + expect(generateLlmsFull).toHaveBeenCalledWith('/test/project'); + expect(generateManifest).toHaveBeenCalledWith('/test/project'); + expect(generateAiIndex).toHaveBeenCalledWith('/test/project'); + expect(copyRawMarkdown).toHaveBeenCalledWith('/test/project'); + }); + + it('should skip disabled generators', async () => { + const { resolveConfig } = await import('./utils'); + vi.mocked(resolveConfig).mockResolvedValueOnce({ + routes: [], + baseUrl: 'https://example.com', + generators: { + robots: true, + sitemap: false, + llmsTxt: true, + llmsFull: false, + manifest: true, + aiIndex: false, + rawMarkdown: false + } + }); + + const { generateRobots } = await import('./robots'); + const { generateSitemap } = await import('./sitemap'); + const { generateLlmsTxt } = await import('./llms-txt'); + const { generateLlmsFull } = await import('./llms-full'); + const { generateManifest } = await import('./manifest'); + const { generateAiIndex } = await import('./ai-index'); + const { copyRawMarkdown } = await import('./raw-markdown'); + + await generateAllWrapper('/test/project'); + + expect(generateRobots).toHaveBeenCalled(); + expect(generateSitemap).not.toHaveBeenCalled(); + expect(generateLlmsTxt).toHaveBeenCalled(); + expect(generateLlmsFull).not.toHaveBeenCalled(); + expect(generateManifest).toHaveBeenCalled(); + expect(generateAiIndex).not.toHaveBeenCalled(); + expect(copyRawMarkdown).not.toHaveBeenCalled(); + }); + + it('should handle generator errors gracefully', async () => { + const { generateRobots } = await import('./robots'); + const { generateSitemap } = await import('./sitemap'); + const { generateLlmsTxt } = await import('./llms-txt'); + + vi.mocked(generateRobots).mockRejectedValueOnce(new Error('Robots generation failed')); + vi.mocked(generateSitemap).mockResolvedValueOnce(undefined); + + await generateAllWrapper('/test/project'); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Error generating robots.txt:'), + expect.any(Error) + ); + expect(generateSitemap).toHaveBeenCalled(); + expect(generateLlmsTxt).toHaveBeenCalled(); + }); + + it('should log progress for each generator', async () => { + await generateAllWrapper('/test/project'); + + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Generating robots.txt')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Generating sitemap.xml')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Generating llms.txt')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Generating llms-full.txt')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Generating docs.json')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Generating ai-index.json')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Copying raw markdown')); + }); + + it('should complete even if all generators fail', async () => { + const { generateRobots } = await import('./robots'); + const { generateSitemap } = await import('./sitemap'); + const { generateLlmsTxt } = await import('./llms-txt'); + const { generateLlmsFull } = await import('./llms-full'); + const { generateManifest } = await import('./manifest'); + const { generateAiIndex } = await import('./ai-index'); + const { copyRawMarkdown } = await import('./raw-markdown'); + + const error = new Error('Generator failed'); + vi.mocked(generateRobots).mockRejectedValue(error); + vi.mocked(generateSitemap).mockRejectedValue(error); + vi.mocked(generateLlmsTxt).mockRejectedValue(error); + vi.mocked(generateLlmsFull).mockRejectedValue(error); + vi.mocked(generateManifest).mockRejectedValue(error); + vi.mocked(generateAiIndex).mockRejectedValue(error); + vi.mocked(copyRawMarkdown).mockRejectedValue(error); + + await expect(generateAllWrapper('/test/project')).resolves.not.toThrow(); + + expect(console.error).toHaveBeenCalledTimes(7); + }); + + it('should use default config when generators not specified', async () => { + const { resolveConfig } = await import('./utils'); + vi.mocked(resolveConfig).mockResolvedValueOnce({ + routes: [], + baseUrl: 'https://example.com' + }); + + const { generateRobots } = await import('./robots'); + const { generateSitemap } = await import('./sitemap'); + + await generateAllWrapper('/test/project'); + + // Should call all generators by default + expect(generateRobots).toHaveBeenCalled(); + expect(generateSitemap).toHaveBeenCalled(); + }); + + it('should pass correct project root to each generator', async () => { + const projectRoot = '/my/custom/project'; + + const { generateRobots } = await import('./robots'); + const { generateSitemap } = await import('./sitemap'); + const { generateLlmsTxt } = await import('./llms-txt'); + + await generateAllWrapper(projectRoot); + + expect(generateRobots).toHaveBeenCalledWith(projectRoot); + expect(generateSitemap).toHaveBeenCalledWith(projectRoot); + expect(generateLlmsTxt).toHaveBeenCalledWith(projectRoot); + }); +}); \ No newline at end of file diff --git a/src/core/generate-wrapper.ts b/src/core/generate-wrapper.ts index c3a27ef..5b1949c 100644 --- a/src/core/generate-wrapper.ts +++ b/src/core/generate-wrapper.ts @@ -9,7 +9,7 @@ import { detectFramework } from './detect'; import { resolveConfig, getAllMarkdownFiles } from './utils'; import { writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; -import type { AeoConfig, FrameworkInfo, ResolvedConfig } from '../types'; +import type { AeoConfig, FrameworkInfo, ResolvedAeoConfig } from '../types'; export interface GenerateResult { markdownFiles: string[]; @@ -26,25 +26,25 @@ interface GenerateFiles { } // Wrapper functions to adapt the signature -export function generateRobotsTxt(files: GenerateFiles, config: ResolvedConfig, root: string): void { +export function generateRobotsTxt(files: GenerateFiles, config: ResolvedAeoConfig, root: string): void { const content = genRobots(config); mkdirSync(config.output || 'public/aeo', { recursive: true }); writeFileSync(join(config.output || 'public/aeo', 'robots.txt'), content, 'utf-8'); } -export function generateLLMsTxt(files: GenerateFiles, config: ResolvedConfig, root: string): void { +export function generateLLMsTxt(files: GenerateFiles, config: ResolvedAeoConfig, root: string): void { const content = genLlms(config); mkdirSync(config.output || 'public/aeo', { recursive: true }); writeFileSync(join(config.output || 'public/aeo', 'llms.txt'), content, 'utf-8'); } -export function generateLLMsFullTxt(files: GenerateFiles, config: ResolvedConfig, root: string): void { +export function generateLLMsFullTxt(files: GenerateFiles, config: ResolvedAeoConfig, root: string): void { const content = genLlmsFull(config); mkdirSync(config.output || 'public/aeo', { recursive: true }); writeFileSync(join(config.output || 'public/aeo', 'llms-full.txt'), content, 'utf-8'); } -export function copyRawMarkdownFiles(files: GenerateFiles, config: ResolvedConfig, root: string): void { +export function copyRawMarkdownFiles(files: GenerateFiles, config: ResolvedAeoConfig, root: string): void { const fullConfig = { ...config, root: root || process.cwd(), @@ -58,23 +58,23 @@ export function copyRawMarkdownFiles(files: GenerateFiles, config: ResolvedConfi sitemap: false, aiIndex: false } - } as ResolvedConfig; + } as ResolvedAeoConfig; copyMarkdownFiles(fullConfig); } -export function generateManifest(files: GenerateFiles, config: ResolvedConfig, root: string): void { +export function generateManifest(files: GenerateFiles, config: ResolvedAeoConfig, root: string): void { const content = genManifest(config); mkdirSync(config.output || 'public/aeo', { recursive: true }); writeFileSync(join(config.output || 'public/aeo', 'docs.json'), content, 'utf-8'); } -export function generateSitemap(files: GenerateFiles, config: ResolvedConfig, root: string): void { +export function generateSitemap(files: GenerateFiles, config: ResolvedAeoConfig, root: string): void { const content = genSitemap(config); mkdirSync(config.output || 'public/aeo', { recursive: true }); writeFileSync(join(config.output || 'public/aeo', 'sitemap.xml'), content, 'utf-8'); } -export function generateAIIndex(files: GenerateFiles, config: ResolvedConfig, root: string): void { +export function generateAIIndex(files: GenerateFiles, config: ResolvedAeoConfig, root: string): void { const content = genAIIndex(config); mkdirSync(config.output || 'public/aeo', { recursive: true }); writeFileSync(join(config.output || 'public/aeo', 'ai-index.json'), content, 'utf-8'); diff --git a/src/core/llms-full.test.ts b/src/core/llms-full.test.ts new file mode 100644 index 0000000..5f6a062 --- /dev/null +++ b/src/core/llms-full.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { generateLlmsFull } from './llms-full'; +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' }, + { path: '/about', title: 'About' }, + { path: '/blog/post1', title: 'Blog Post 1' } + ], + baseUrl: 'https://example.com' + }), + ensureDir: vi.fn().mockResolvedValue(undefined) +})); + +describe('generateLlmsFull', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should concatenate all page content into llms-full.txt', async () => { + const mockReadFile = vi.mocked(fs.readFile); + const mockWriteFile = vi.mocked(fs.writeFile); + + mockReadFile.mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.endsWith('/index.html')) { + return Promise.resolve(Buffer.from(` + + + +

Welcome Home

+

This is the homepage.

+ + + `)); + } else if (pathStr.endsWith('/about.html')) { + return Promise.resolve(Buffer.from(` + + + +

About Us

+

Learn more about our company.

+ + + `)); + } else if (pathStr.endsWith('/blog/post1.html')) { + return Promise.resolve(Buffer.from(` + + + +
+

First Blog Post

+

This is an interesting article.

+
+ + + `)); + } + return Promise.resolve(Buffer.from('')); + }); + + await generateLlmsFull('/test/project'); + + expect(mockWriteFile).toHaveBeenCalledWith( + path.join('/test/project', 'public', 'llms-full.txt'), + expect.any(String) + ); + + const content = mockWriteFile.mock.calls[0][1] as string; + + expect(content).toContain('# Full Content Export'); + expect(content).toContain('## Page: Home'); + expect(content).toContain('URL: https://example.com/'); + expect(content).toContain('Welcome Home'); + expect(content).toContain('This is the homepage'); + + expect(content).toContain('## Page: About'); + expect(content).toContain('URL: https://example.com/about'); + expect(content).toContain('About Us'); + expect(content).toContain('Learn more about our company'); + + expect(content).toContain('## Page: Blog Post 1'); + expect(content).toContain('URL: https://example.com/blog/post1'); + expect(content).toContain('First Blog Post'); + expect(content).toContain('This is an interesting article'); + }); + + it('should handle missing HTML files gracefully', async () => { + const mockReadFile = vi.mocked(fs.readFile); + const mockWriteFile = vi.mocked(fs.writeFile); + + mockReadFile.mockRejectedValue(new Error('ENOENT: File not found')); + + await generateLlmsFull('/test/project'); + + expect(mockWriteFile).toHaveBeenCalled(); + const content = mockWriteFile.mock.calls[0][1] as string; + + expect(content).toContain('## Page: Home'); + expect(content).toContain('[Content not available]'); + }); + + it('should strip scripts and styles from content', async () => { + const mockReadFile = vi.mocked(fs.readFile); + const mockWriteFile = vi.mocked(fs.writeFile); + + mockReadFile.mockResolvedValue(Buffer.from(` + + + + + + +

Page Title

+

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 separator between pages', async () => { + const mockWriteFile = vi.mocked(fs.writeFile); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('Content')); + + 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 diff --git a/src/core/llms-full.ts b/src/core/llms-full.ts index 3788243..a5f4d69 100644 --- a/src/core/llms-full.ts +++ b/src/core/llms-full.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync } from 'fs'; import { join, relative, extname } from 'path'; -import type { ResolvedConfig } from '../types'; +import type { ResolvedAeoConfig } from '../types'; import { parseFrontmatter, bumpHeadings } from './utils'; function collectAndConcatenateMarkdown(dir: string, base: string = dir): string[] { @@ -51,7 +51,7 @@ function collectAndConcatenateMarkdown(dir: string, base: string = dir): string[ return sections; } -export function generateLlmsFullTxt(config: ResolvedConfig): string { +export function generateLlmsFullTxt(config: ResolvedAeoConfig): string { const lines: string[] = [ `# ${config.title} - Complete Documentation`, '', diff --git a/src/core/llms-txt.test.ts b/src/core/llms-txt.test.ts index 116c514..3596965 100644 --- a/src/core/llms-txt.test.ts +++ b/src/core/llms-txt.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { generateLlmsTxt } from './llms-txt' import fs from 'fs' import path from 'path' -import type { ResolvedConfig } from '../types' +import type { ResolvedAeoConfig } from '../types' vi.mock('fs') vi.mock('path') @@ -31,7 +31,7 @@ describe('generateLlmsTxt', () => { mockFs.readFileSync.mockReturnValue('# Test Title\n\nTest content\n\n## Section\n\nMore content') }) - const baseConfig: ResolvedConfig = { + const baseConfig: ResolvedAeoConfig = { url: 'https://example.com', title: 'Test Project', description: 'A test project description', @@ -99,7 +99,7 @@ describe('generateLlmsTxt', () => { }) it('should handle missing description', () => { - const config: ResolvedConfig = { + const config: ResolvedAeoConfig = { ...baseConfig, description: undefined as any, } diff --git a/src/core/llms-txt.ts b/src/core/llms-txt.ts index 3eeeabc..71573be 100644 --- a/src/core/llms-txt.ts +++ b/src/core/llms-txt.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync } from 'fs'; import { join, relative, extname } from 'path'; -import type { ResolvedConfig, MarkdownFile } from '../types'; +import type { ResolvedAeoConfig, MarkdownFile } from '../types'; import { parseFrontmatter, extractTitle } from './utils'; function collectMarkdownFiles(dir: string, base: string = dir): MarkdownFile[] { @@ -36,7 +36,7 @@ function collectMarkdownFiles(dir: string, base: string = dir): MarkdownFile[] { return files; } -export function generateLlmsTxt(config: ResolvedConfig): string { +export function generateLlmsTxt(config: ResolvedAeoConfig): string { const lines: string[] = [ `# ${config.title}`, '', diff --git a/src/core/manifest.test.ts b/src/core/manifest.test.ts new file mode 100644 index 0000000..56bc75c --- /dev/null +++ b/src/core/manifest.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } 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) +})); + +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); + + 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.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]; + expect(homeDoc).toMatchObject({ + id: expect.stringMatching(/^[a-f0-9]{8}$/), + url: 'https://example.com/', + title: 'Home', + description: 'Homepage', + keywords: ['home', 'main'] + }); + + const aboutDoc = manifest.documents[1]; + 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 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([]); + }); + + 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' + }); + + 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); + const beforeTime = new Date().toISOString(); + + await generateManifest('/test/project'); + + const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + 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); + }); +}); \ No newline at end of file diff --git a/src/core/manifest.ts b/src/core/manifest.ts index 1593920..e6ce1a4 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -1,9 +1,9 @@ import { readdirSync, readFileSync, statSync } from 'fs'; import { join, relative, extname } from 'path'; -import type { ResolvedConfig, ManifestEntry } from '../types'; +import type { ResolvedAeoConfig, ManifestEntry } from '../types'; import { parseFrontmatter, extractTitle } from './utils'; -function collectManifestEntries(dir: string, config: ResolvedConfig, base: string = dir): ManifestEntry[] { +function collectManifestEntries(dir: string, config: ResolvedAeoConfig, base: string = dir): ManifestEntry[] { const entries: ManifestEntry[] = []; try { @@ -36,7 +36,7 @@ function collectManifestEntries(dir: string, config: ResolvedConfig, base: strin return entries; } -export function generateManifest(config: ResolvedConfig): string { +export function generateManifest(config: ResolvedAeoConfig): string { const entries = collectManifestEntries(config.contentDir, config); const manifest = { diff --git a/src/core/raw-markdown.test.ts b/src/core/raw-markdown.test.ts new file mode 100644 index 0000000..5b675f1 --- /dev/null +++ b/src/core/raw-markdown.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { copyRawMarkdown } from './raw-markdown'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +vi.mock('fs/promises'); +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + join: vi.fn((...args) => args.join('/')) + }; +}); + +vi.mock('./utils', () => ({ + resolveConfig: vi.fn().mockResolvedValue({ + routes: [], + baseUrl: 'https://example.com', + markdownDir: 'content' + }), + ensureDir: vi.fn().mockResolvedValue(undefined) +})); + +describe('copyRawMarkdown', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should copy markdown files from source to public directory', async () => { + const mockReaddir = vi.mocked(fs.readdir); + const mockStat = vi.mocked(fs.stat); + const mockCopyFile = vi.mocked(fs.copyFile); + + mockReaddir.mockResolvedValue([ + { name: 'page1.md', isFile: () => true, isDirectory: () => false }, + { name: 'page2.md', isFile: () => true, isDirectory: () => false }, + { name: 'image.png', isFile: () => true, isDirectory: () => false }, + { name: 'subfolder', isFile: () => false, isDirectory: () => true } + ] as any); + + mockStat.mockResolvedValue({ + isFile: () => true, + isDirectory: () => false + } as any); + + await copyRawMarkdown('/test/project'); + + expect(mockCopyFile).toHaveBeenCalledTimes(2); + expect(mockCopyFile).toHaveBeenCalledWith( + '/test/project/content/page1.md', + '/test/project/public/raw-markdown/page1.md' + ); + expect(mockCopyFile).toHaveBeenCalledWith( + '/test/project/content/page2.md', + '/test/project/public/raw-markdown/page2.md' + ); + }); + + it('should recursively copy markdown files from subdirectories', async () => { + const mockReaddir = vi.mocked(fs.readdir); + const mockStat = vi.mocked(fs.stat); + const mockCopyFile = vi.mocked(fs.copyFile); + const { ensureDir } = await import('./utils'); + + mockReaddir.mockImplementation((dirPath) => { + const pathStr = dirPath.toString(); + if (pathStr.endsWith('/content')) { + return Promise.resolve([ + { name: 'docs', isFile: () => false, isDirectory: () => true }, + { name: 'root.md', isFile: () => true, isDirectory: () => false } + ] as any); + } else if (pathStr.endsWith('/docs')) { + return Promise.resolve([ + { name: 'guide.md', isFile: () => true, isDirectory: () => false }, + { name: 'api', isFile: () => false, isDirectory: () => true } + ] as any); + } else if (pathStr.endsWith('/api')) { + return Promise.resolve([ + { name: 'reference.md', isFile: () => true, isDirectory: () => false } + ] as any); + } + return Promise.resolve([]); + }); + + mockStat.mockResolvedValue({ + isFile: () => true, + isDirectory: () => false + } as any); + + await copyRawMarkdown('/test/project'); + + expect(mockCopyFile).toHaveBeenCalledWith( + '/test/project/content/root.md', + '/test/project/public/raw-markdown/root.md' + ); + expect(mockCopyFile).toHaveBeenCalledWith( + '/test/project/content/docs/guide.md', + '/test/project/public/raw-markdown/docs/guide.md' + ); + expect(mockCopyFile).toHaveBeenCalledWith( + '/test/project/content/docs/api/reference.md', + '/test/project/public/raw-markdown/docs/api/reference.md' + ); + + expect(ensureDir).toHaveBeenCalledWith('/test/project/public/raw-markdown/docs'); + expect(ensureDir).toHaveBeenCalledWith('/test/project/public/raw-markdown/docs/api'); + }); + + it('should skip non-markdown files', async () => { + const mockReaddir = vi.mocked(fs.readdir); + const mockCopyFile = vi.mocked(fs.copyFile); + + mockReaddir.mockResolvedValue([ + { name: 'document.md', isFile: () => true, isDirectory: () => false }, + { name: 'script.js', isFile: () => true, isDirectory: () => false }, + { name: 'style.css', isFile: () => true, isDirectory: () => false }, + { name: 'README.MD', isFile: () => true, isDirectory: () => false }, + { name: 'data.json', isFile: () => true, isDirectory: () => false } + ] as any); + + await copyRawMarkdown('/test/project'); + + expect(mockCopyFile).toHaveBeenCalledTimes(2); + expect(mockCopyFile).toHaveBeenCalledWith( + expect.stringContaining('document.md'), + expect.stringContaining('document.md') + ); + expect(mockCopyFile).toHaveBeenCalledWith( + expect.stringContaining('README.MD'), + expect.stringContaining('README.MD') + ); + }); + + it('should handle missing markdown directory gracefully', async () => { + const mockReaddir = vi.mocked(fs.readdir); + + mockReaddir.mockRejectedValue(new Error('ENOENT: Directory not found')); + + await expect(copyRawMarkdown('/test/project')).resolves.not.toThrow(); + }); + + it('should use custom markdown directory from config', async () => { + const { resolveConfig } = await import('./utils'); + vi.mocked(resolveConfig).mockResolvedValueOnce({ + routes: [], + baseUrl: 'https://example.com', + markdownDir: 'custom-docs' + }); + + const mockReaddir = vi.mocked(fs.readdir); + mockReaddir.mockResolvedValue([ + { name: 'test.md', isFile: () => true, isDirectory: () => false } + ] as any); + + const mockCopyFile = vi.mocked(fs.copyFile); + + await copyRawMarkdown('/test/project'); + + expect(mockReaddir).toHaveBeenCalledWith( + '/test/project/custom-docs', + expect.any(Object) + ); + expect(mockCopyFile).toHaveBeenCalledWith( + '/test/project/custom-docs/test.md', + expect.any(String) + ); + }); + + it('should handle copy errors for individual files', async () => { + const mockReaddir = vi.mocked(fs.readdir); + const mockCopyFile = vi.mocked(fs.copyFile); + + mockReaddir.mockResolvedValue([ + { name: 'file1.md', isFile: () => true, isDirectory: () => false }, + { name: 'file2.md', isFile: () => true, isDirectory: () => false } + ] as any); + + mockCopyFile + .mockRejectedValueOnce(new Error('Permission denied')) + .mockResolvedValueOnce(undefined); + + await copyRawMarkdown('/test/project'); + + expect(mockCopyFile).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file diff --git a/src/core/raw-markdown.ts b/src/core/raw-markdown.ts index 9016176..68dd904 100644 --- a/src/core/raw-markdown.ts +++ b/src/core/raw-markdown.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, copyFileSync } from 'fs'; import { join, relative, extname, dirname } from 'path'; -import type { ResolvedConfig } from '../types'; +import type { ResolvedAeoConfig } from '../types'; export interface CopiedFile { source: string; @@ -11,7 +11,7 @@ function ensureDir(path: string): void { mkdirSync(path, { recursive: true }); } -export function copyMarkdownFiles(config: ResolvedConfig): CopiedFile[] { +export function copyMarkdownFiles(config: ResolvedAeoConfig): CopiedFile[] { const copiedFiles: CopiedFile[] = []; function copyRecursive(dir: string, base: string = config.contentDir): void { diff --git a/src/core/robots.test.ts b/src/core/robots.test.ts index 08666d6..9c67690 100644 --- a/src/core/robots.test.ts +++ b/src/core/robots.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest' import { generateRobotsTxt } from './robots' -import type { ResolvedConfig } from '../types' +import type { ResolvedAeoConfig } from '../types' describe('generateRobotsTxt', () => { - const baseConfig: ResolvedConfig = { + const baseConfig: ResolvedAeoConfig = { url: 'https://example.com', title: 'Test Site', description: 'Test description', @@ -53,7 +53,7 @@ describe('generateRobotsTxt', () => { }) it('should handle missing url', () => { - const config: ResolvedConfig = { + const config: ResolvedAeoConfig = { ...baseConfig, url: '', } diff --git a/src/core/robots.ts b/src/core/robots.ts index 320c35d..690a916 100644 --- a/src/core/robots.ts +++ b/src/core/robots.ts @@ -1,4 +1,4 @@ -import type { ResolvedConfig } from '../types'; +import type { ResolvedAeoConfig } from '../types'; const AI_CRAWLERS = [ 'GPTBot', @@ -53,7 +53,7 @@ const AI_CRAWLERS = [ 'TikTok', ]; -export function generateRobotsTxt(config: ResolvedConfig): string { +export function generateRobotsTxt(config: ResolvedAeoConfig): string { const lines: string[] = [ '# robots.txt generated by aeo.js', '# Allow AI crawlers to index this site', diff --git a/src/core/sitemap.test.ts b/src/core/sitemap.test.ts new file mode 100644 index 0000000..e722d77 --- /dev/null +++ b/src/core/sitemap.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { generateSitemap } from './sitemap'; +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', priority: 1.0 }, + { path: '/about', title: 'About', priority: 0.8 }, + { path: '/products', title: 'Products' }, + { path: '/contact', title: 'Contact', priority: 0.5 } + ], + baseUrl: 'https://example.com' + }), + ensureDir: vi.fn().mockResolvedValue(undefined) +})); + +describe('generateSitemap', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-15T10:30:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should generate sitemap.xml with all routes', async () => { + const mockWriteFile = vi.mocked(fs.writeFile); + + await generateSitemap('/test/project'); + + expect(mockWriteFile).toHaveBeenCalledWith( + path.join('/test/project', 'public', 'sitemap.xml'), + expect.any(String) + ); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + + expect(sitemap).toContain(''); + expect(sitemap).toContain(''); + expect(sitemap).toContain(''); + + expect(sitemap).toContain('https://example.com/'); + expect(sitemap).toContain('https://example.com/about'); + expect(sitemap).toContain('https://example.com/products'); + expect(sitemap).toContain('https://example.com/contact'); + }); + + it('should include priority when specified', async () => { + const mockWriteFile = vi.mocked(fs.writeFile); + + await generateSitemap('/test/project'); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + + expect(sitemap).toMatch(/\s*https:\/\/example\.com\/<\/loc>\s*2024-01-15<\/lastmod>\s*1\.0<\/priority>\s*<\/url>/); + expect(sitemap).toMatch(/\s*https:\/\/example\.com\/about<\/loc>\s*2024-01-15<\/lastmod>\s*0\.8<\/priority>\s*<\/url>/); + expect(sitemap).toMatch(/\s*https:\/\/example\.com\/contact<\/loc>\s*2024-01-15<\/lastmod>\s*0\.5<\/priority>\s*<\/url>/); + }); + + it('should use default priority when not specified', async () => { + const mockWriteFile = vi.mocked(fs.writeFile); + + await generateSitemap('/test/project'); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + + expect(sitemap).toMatch(/\s*https:\/\/example\.com\/products<\/loc>\s*2024-01-15<\/lastmod>\s*0\.5<\/priority>\s*<\/url>/); + }); + + it('should include lastmod with current date', async () => { + const mockWriteFile = vi.mocked(fs.writeFile); + + await generateSitemap('/test/project'); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + + expect(sitemap).toContain('2024-01-15'); + }); + + 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 generateSitemap('/test/project'); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + + expect(sitemap).toContain(''); + expect(sitemap).toContain(''); + expect(sitemap).toContain(''); + expect(sitemap).not.toContain(''); + }); + + it('should escape special characters in URLs', async () => { + const { resolveConfig } = await import('./utils'); + vi.mocked(resolveConfig).mockResolvedValueOnce({ + routes: [ + { path: '/search?q=test&category=books', title: 'Search' }, + { path: '/product/', title: 'Product' } + ], + baseUrl: 'https://example.com' + }); + + const mockWriteFile = vi.mocked(fs.writeFile); + + await generateSitemap('/test/project'); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + + expect(sitemap).toContain('https://example.com/search?q=test&category=books'); + expect(sitemap).toContain('https://example.com/product/<id>'); + }); + + it('should format XML with proper indentation', async () => { + const mockWriteFile = vi.mocked(fs.writeFile); + + await generateSitemap('/test/project'); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + const lines = sitemap.split('\n'); + + expect(lines[0]).toBe(''); + expect(lines[1]).toBe(''); + expect(lines.some(line => line.startsWith(' '))).toBe(true); + expect(lines.some(line => line.startsWith(' '))).toBe(true); + expect(lines[lines.length - 2]).toBe(''); + }); + + it('should handle routes with trailing slashes correctly', async () => { + const { resolveConfig } = await import('./utils'); + vi.mocked(resolveConfig).mockResolvedValueOnce({ + routes: [ + { path: '/about/', title: 'About' }, + { path: '/products/', title: 'Products' } + ], + baseUrl: 'https://example.com' + }); + + const mockWriteFile = vi.mocked(fs.writeFile); + + await generateSitemap('/test/project'); + + const sitemap = mockWriteFile.mock.calls[0][1] as string; + + expect(sitemap).toContain('https://example.com/about/'); + expect(sitemap).toContain('https://example.com/products/'); + }); +}); \ No newline at end of file diff --git a/src/core/sitemap.ts b/src/core/sitemap.ts index 8492323..17f5d1b 100644 --- a/src/core/sitemap.ts +++ b/src/core/sitemap.ts @@ -1,8 +1,8 @@ import { readdirSync, statSync } from 'fs'; import { join, relative, extname } from 'path'; -import type { ResolvedConfig } from '../types'; +import type { ResolvedAeoConfig } from '../types'; -function collectUrls(dir: string, config: ResolvedConfig, base: string = dir): string[] { +function collectUrls(dir: string, config: ResolvedAeoConfig, base: string = dir): string[] { const urls: string[] = []; try { @@ -27,7 +27,7 @@ function collectUrls(dir: string, config: ResolvedConfig, base: string = dir): s return urls; } -export function generateSitemap(config: ResolvedConfig): string { +export function generateSitemap(config: ResolvedAeoConfig): string { const urls = collectUrls(config.contentDir, config); const lines: string[] = [ diff --git a/src/core/utils.ts b/src/core/utils.ts index 6706202..c0a2445 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,10 +1,10 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; import { join, relative, extname } from 'path'; -import type { AeoConfig, ResolvedConfig, MarkdownFile } from '../types'; +import type { AeoConfig, ResolvedAeoConfig, MarkdownFile } from '../types'; import { detectFramework } from './detect'; import { minimatch } from 'minimatch'; -export function resolveConfig(config: AeoConfig = {}): ResolvedConfig { +export function resolveConfig(config: AeoConfig = {}): ResolvedAeoConfig { const frameworkInfo = detectFramework(); return { diff --git a/src/index.ts b/src/index.ts index f56427a..a876068 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import type { AeoConfig, ResolvedConfig } from './types'; +import type { AeoConfig } from './types'; export const VERSION = '0.0.1'; @@ -6,8 +6,20 @@ export function defineConfig(config: AeoConfig): AeoConfig { return config; } -export type { AeoConfig, ResolvedConfig, MarkdownFile, ManifestEntry, AIIndexEntry, Framework, FrameworkInfo } from './types'; +// Export all types +export type { + AeoConfig, + ResolvedAeoConfig, + DocEntry, + AeoManifest, + MarkdownFile, + ManifestEntry, + AIIndexEntry, + FrameworkType, + FrameworkInfo +} from './types'; +// Export core functions export { detectFramework } from './core/detect'; -export { generateAeoFiles } from './core/generate'; +export { generateAEOFiles as generateAll } from './core/generate'; export { resolveConfig } from './core/utils'; \ No newline at end of file diff --git a/src/plugins/astro.ts b/src/plugins/astro.ts index 658733e..fd18f55 100644 --- a/src/plugins/astro.ts +++ b/src/plugins/astro.ts @@ -1,5 +1,5 @@ import type { AstroIntegration } from 'astro'; -import { generateAeoFiles } from '../core/generate'; +import { generateAEOFiles } from '../core/generate'; import { resolveConfig } from '../core/utils'; import type { AeoConfig } from '../types'; import { join } from 'path'; @@ -73,7 +73,7 @@ export function aeoAstroIntegration(options: AeoConfig = {}): AstroIntegration { }); try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { buildLogger.info(`Generated ${result.files.length} files`); @@ -100,7 +100,7 @@ export function aeoAstroIntegration(options: AeoConfig = {}): AstroIntegration { devLogger.info('Generating AEO files for development...'); try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { devLogger.info(`Generated ${result.files.length} files`); @@ -126,7 +126,7 @@ export function aeoAstroIntegration(options: AeoConfig = {}): AstroIntegration { devLogger.info('Content file changed, regenerating AEO files...'); try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { devLogger.info(`Regenerated ${result.files.length} files`); diff --git a/src/plugins/next.ts b/src/plugins/next.ts index ab3dd09..821db1e 100644 --- a/src/plugins/next.ts +++ b/src/plugins/next.ts @@ -1,5 +1,5 @@ import type { NextConfig } from 'next'; -import { generateAeoFiles } from '../core/generate'; +import { generateAEOFiles } from '../core/generate'; import { resolveConfig } from '../core/utils'; import type { AeoConfig } from '../types'; import { join } from 'path'; @@ -41,7 +41,7 @@ export function withAeo(nextConfig: NextAeoConfig = {}): NextConfig { console.log('[aeo.js] Generating AEO files for Next.js...'); try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { console.log(`[aeo.js] Generated ${result.files.length} files:`); @@ -122,7 +122,7 @@ export async function generateAeoMetadata(config?: AeoConfig) { // Generate files during build if (process.env.NODE_ENV === 'production') { - await generateAeoFiles(resolvedConfig); + await generateAEOFiles(resolvedConfig); } return { diff --git a/src/plugins/nuxt.ts b/src/plugins/nuxt.ts index 386e6e6..93ce431 100644 --- a/src/plugins/nuxt.ts +++ b/src/plugins/nuxt.ts @@ -1,5 +1,5 @@ import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit'; -import { generateAeoFiles } from '../core/generate'; +import { generateAEOFiles } from '../core/generate'; import { resolveConfig } from '../core/utils'; import type { AeoConfig } from '../types'; import { join } from 'path'; @@ -43,7 +43,7 @@ export default defineNuxtModule({ } try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { console.log(`[aeo.js] Generated ${result.files.length} files:`); @@ -78,7 +78,7 @@ export default defineNuxtModule({ } try { - const result = await generateAeoFiles(prodConfig); + const result = await generateAEOFiles(prodConfig); if (result.files.length > 0) { console.log(`[aeo.js] Generated ${result.files.length} files for production`); @@ -99,7 +99,7 @@ export default defineNuxtModule({ console.log('[aeo.js] Content changed, regenerating AEO files...'); try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { console.log(`[aeo.js] Regenerated ${result.files.length} files`); diff --git a/src/plugins/vite.ts b/src/plugins/vite.ts index c5f9f9c..3cac96c 100644 --- a/src/plugins/vite.ts +++ b/src/plugins/vite.ts @@ -1,5 +1,5 @@ import type { Plugin } from 'vite'; -import { generateAeoFiles } from '../core/generate'; +import { generateAEOFiles } from '../core/generate'; import { resolveConfig } from '../core/utils'; import type { AeoConfig } from '../types'; import { join } from 'path'; @@ -34,7 +34,7 @@ export function aeoVitePlugin(options: AeoConfig = {}): Plugin { console.log('[aeo.js] Generating AEO files...'); try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { console.log(`[aeo.js] Generated ${result.files.length} files:`); @@ -64,7 +64,7 @@ export function aeoVitePlugin(options: AeoConfig = {}): Plugin { console.log('[aeo.js] Markdown file changed, regenerating...'); try { - const result = await generateAeoFiles(resolvedConfig); + const result = await generateAEOFiles(resolvedConfig); if (result.files.length > 0) { console.log(`[aeo.js] Regenerated ${result.files.length} files`); diff --git a/src/plugins/webpack.ts b/src/plugins/webpack.ts index 604beea..4366793 100644 --- a/src/plugins/webpack.ts +++ b/src/plugins/webpack.ts @@ -1,5 +1,5 @@ import type { Compiler, WebpackPluginInstance } from 'webpack'; -import { generateAeoFiles } from '../core/generate'; +import { generateAEOFiles } from '../core/generate'; import { resolveConfig } from '../core/utils'; import type { AeoConfig } from '../types'; import { join } from 'path'; @@ -27,7 +27,7 @@ export class AeoWebpackPlugin implements WebpackPluginInstance { console.log('[aeo.js] Generating AEO files...'); try { - const result = await generateAeoFiles(this.resolvedConfig); + const result = await generateAEOFiles(this.resolvedConfig); if (result.files.length > 0) { console.log(`[aeo.js] Generated ${result.files.length} files:`); @@ -71,7 +71,7 @@ export class AeoWebpackPlugin implements WebpackPluginInstance { console.log('[aeo.js] Markdown files changed, regenerating...'); try { - const result = await generateAeoFiles(this.resolvedConfig); + const result = await generateAEOFiles(this.resolvedConfig); if (result.files.length > 0) { console.log(`[aeo.js] Regenerated ${result.files.length} files`); diff --git a/src/types.ts b/src/types.ts index a33b10d..217950c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,12 @@ export interface AeoConfig { sitemap?: boolean; aiIndex?: boolean; }; + robots?: { + allow?: string[]; + disallow?: string[]; + crawlDelay?: number; + sitemap?: string; + }; widget?: { enabled?: boolean; position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; @@ -28,7 +34,7 @@ export interface AeoConfig { }; } -export interface ResolvedConfig extends AeoConfig { +export interface ResolvedAeoConfig { title: string; description: string; url: string; @@ -43,6 +49,12 @@ export interface ResolvedConfig extends AeoConfig { sitemap: boolean; aiIndex: boolean; }; + robots: { + allow: string[]; + disallow: string[]; + crawlDelay: number; + sitemap: string; + }; widget: { enabled: boolean; position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; @@ -58,6 +70,32 @@ export interface ResolvedConfig extends AeoConfig { }; } +export interface DocEntry { + title: string; + description?: string; + path: string; + markdownUrl: string; + htmlUrl: string; + category?: string; + keywords?: string[]; + content: string; +} + +export interface AeoManifest { + name: string; + description: string; + baseUrl: string; + generatedAt: string; + totalDocs: number; + access: { + llmsTxt: boolean; + llmsFullTxt: boolean; + sitemap: boolean; + rawMarkdown: boolean; + }; + docs: DocEntry[]; +} + export interface MarkdownFile { path: string; content: string; @@ -83,10 +121,10 @@ export interface AIIndexEntry { metadata?: Record; } -export type Framework = 'next' | 'nuxt' | 'astro' | 'remix' | 'sveltekit' | 'angular' | 'docusaurus' | 'vite' | 'unknown'; +export type FrameworkType = 'next' | 'vite' | 'nuxt' | 'astro' | 'remix' | 'sveltekit' | 'angular' | 'docusaurus' | 'vanilla' | 'unknown'; export interface FrameworkInfo { - framework: Framework; + framework: FrameworkType; contentDir: string; outDir: string; } \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index 875d5f4..ecd3aa2 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,18 +2,17 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: { - 'index': 'src/index.ts', - 'plugins/vite': 'src/plugins/vite.ts', - 'plugins/next': 'src/plugins/next.ts', - 'plugins/astro': 'src/plugins/astro.ts', - 'plugins/nuxt': 'src/plugins/nuxt.ts', - 'plugins/webpack': 'src/plugins/webpack.ts', - 'widget/react': 'src/widget/react.tsx', - 'widget/vue': 'src/widget/vue.ts', - 'widget/svelte': 'src/widget/svelte.ts', - 'widget/core': 'src/widget/core.ts', + index: 'src/index.ts', + vite: 'src/plugins/vite.ts', + next: 'src/plugins/next.ts', + webpack: 'src/plugins/webpack.ts', + astro: 'src/plugins/astro.ts', + nuxt: 'src/plugins/nuxt.ts', + widget: 'src/widget/core.ts', + react: 'src/widget/react.tsx', + vue: 'src/widget/vue.ts', }, - format: ['cjs', 'esm'], + format: ['esm', 'cjs'], dts: true, splitting: false, sourcemap: true,