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,